Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27ca5b2349 | ||
|
|
20b12255e1 | ||
|
|
71a3fae655 | ||
|
|
c3984da623 | ||
|
|
2e3f4ada38 | ||
|
|
03c6be80a3 | ||
|
|
4afc453faa | ||
|
|
1aab61bf26 | ||
|
|
dc01f88d75 | ||
|
|
c589a75fa0 | ||
|
|
4b62cc642e | ||
|
|
80c2eadec9 | ||
|
|
0b587629e6 | ||
|
|
3163256d2c | ||
|
|
6102e0d4d9 | ||
|
|
f0da383e28 | ||
|
|
2d3695a1d3 | ||
|
|
f06ee259b4 | ||
|
|
560a74caf8 | ||
|
|
fd7e17523d | ||
|
|
c7682297fa | ||
|
|
27511302f2 | ||
|
|
184a6c5b33 | ||
|
|
5b2ca039f1 | ||
|
|
a8d24553d5 | ||
|
|
3b80a88f3b | ||
|
|
d8e6bc6e9b | ||
|
|
b887a96765 | ||
|
|
2aaa3733c3 | ||
|
|
46246ea511 | ||
|
|
a27fbdb029 | ||
|
|
c07d544aeb | ||
|
|
46d3a6fd41 | ||
|
|
5655fa8093 | ||
|
|
b3b1d47dd6 | ||
|
|
50fe4828a2 | ||
|
|
800da46188 | ||
|
|
00767eed4d | ||
|
|
683db4908a | ||
|
|
8d23a20792 | ||
|
|
d01c105037 | ||
|
|
88631f5e8b | ||
|
|
e6924298bc | ||
|
|
68b48cfd14 | ||
|
|
e6c884a0cd | ||
|
|
a6cb9a9082 | ||
|
|
0be6a571c4 | ||
|
|
bfe93c4188 | ||
|
|
5b7dc0e4e2 | ||
|
|
621f08d725 | ||
|
|
e49d0e606f | ||
|
|
e2a1fadbec | ||
|
|
0e4629361b | ||
|
|
1e7b1cddb7 | ||
|
|
470f8e5019 | ||
|
|
cf10b17c5b | ||
|
|
7ae53ad797 | ||
|
|
d17040b601 | ||
|
|
bf5087a598 | ||
|
|
9fa09b0af1 | ||
|
|
aa3d11471f | ||
|
|
78aff64844 | ||
|
|
9a33cb5384 | ||
|
|
45ced405f3 | ||
|
|
62199aa3a7 | ||
|
|
b133d85943 | ||
|
|
ba6817fee5 | ||
|
|
73ee63bc1b | ||
|
|
8f0aec449a | ||
|
|
6d5fd64bb0 | ||
|
|
e5880c33f4 | ||
|
|
a853eb5a4d | ||
|
|
eff5c8b0c0 | ||
|
|
7b63330aaa | ||
|
|
22d5c6585a | ||
|
|
b063fbd7f9 | ||
|
|
3f25e7ebca | ||
|
|
0af4c88d08 | ||
|
|
ceabd00805 | ||
|
|
32a5256a0d | ||
|
|
4cfe0ef6e6 | ||
|
|
c9b273ff16 | ||
|
|
8adda94a7a | ||
|
|
3a9208f38b | ||
|
|
e898370bf4 | ||
|
|
6e0bd06e4d | ||
|
|
03da47e550 | ||
|
|
a2cd119985 | ||
|
|
19c36e37f2 | ||
|
|
6bdec6e785 | ||
|
|
e2873df92e | ||
|
|
ea13889a21 | ||
|
|
6317685d1a | ||
|
|
f79bd7ca71 | ||
|
|
9c935f8ce8 | ||
|
|
982449293d | ||
|
|
288853c094 | ||
|
|
fba572427d | ||
|
|
85ec5416b6 | ||
|
|
643daf5637 | ||
|
|
8db0184384 | ||
|
|
1a6599e1b2 | ||
|
|
0a2f4fa1fe | ||
|
|
237886c11e | ||
|
|
e8dbcaa7db | ||
|
|
26163b25b2 | ||
|
|
762c1290a1 | ||
|
|
62dd6b7912 | ||
|
|
bc3db183e3 | ||
|
|
e0a473e090 | ||
|
|
1c937e2f48 | ||
|
|
d194d73439 | ||
|
|
4400966928 | ||
|
|
4821a02bd3 | ||
|
|
6e49ce8c92 | ||
|
|
1ff662c7c3 | ||
|
|
79b9cd789a | ||
|
|
c70a670356 | ||
|
|
43743ba171 | ||
|
|
1a97d0ef5c | ||
|
|
ff7e9c0435 | ||
|
|
68a7f41ed0 | ||
|
|
9b331a5e93 | ||
|
|
3fc224b584 | ||
|
|
10500ae8aa | ||
|
|
8d441d3d59 | ||
|
|
e4f0935f98 | ||
|
|
3c0214ece8 | ||
|
|
127b25e60a |
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
||||||
|
# every crate in this repo into one workspace -- they are deliberately
|
||||||
|
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
|
||||||
|
# from the repo root runs xtask/src/main.rs directly.
|
||||||
|
[alias]
|
||||||
|
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
---
|
||||||
|
name: ai-app-rigs
|
||||||
|
description: ai-app's test rigs, harness scripts and reference measurements - ui-sandbox.sh, debug-transcript.sh, transcript-bench.sh, stream-bench.sh, trace-draw.sh, the /usage fixture vocabulary, the fake CLI, the rule that no UI-driving script may tap a coordinate, how to test llama.cpp and ssh on this machine, how importing behaves, and the scroll/stream/explorer numbers not worth re-measuring. Read before running or writing a benchmark, driving the app's UI from a script, exercising the session lifecycle, testing a llama or remote session, or touching the import screen.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ai-app: rigs, harnesses and measurements
|
||||||
|
|
||||||
|
Moved out of `AGENTS.md` on 2026-09-04 so it is read when it is relevant
|
||||||
|
rather than sent with every request in this repo -- it was 12 KB of the 35 KB
|
||||||
|
that file cost on every one. Unchanged in the move, and still the only copy.
|
||||||
|
|
||||||
|
## The rigs
|
||||||
|
|
||||||
|
Each exists because something was invisible without it.
|
||||||
|
|
||||||
|
- **`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.
|
||||||
|
- **`/usage` in an echo session puts up an invented meter**, which is how the
|
||||||
|
rate-limit screens' states are reached without spending quota: `/usage 42`,
|
||||||
|
`/usage 95 20` (minutes left), `/usage 42 never` (the between-blocks window
|
||||||
|
with no reset time), `/usage 42 unreadable`, `/usage notloggedin`,
|
||||||
|
`/usage unreachable`, `/usage failed`, `/usage off`. The vocabulary is
|
||||||
|
`usage::Fixture`'s, since those are its states. With none set an echo
|
||||||
|
session meters nothing, which is the ordinary case and draws no bar.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### Driving the 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:
|
||||||
|
|
||||||
|
grep -n "tap [0-9]" app/*.sh
|
||||||
|
|
||||||
|
Swipes are still coordinates, deliberately: a gesture across a scrolling area
|
||||||
|
is a distance rather than a control.
|
||||||
|
|
||||||
|
**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*.
|
||||||
|
|
||||||
|
**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 …`.
|
||||||
|
|
||||||
|
### Testing llama.cpp and ssh here
|
||||||
|
|
||||||
|
**Both are set up here as of 2026-09-04** and need nothing typed. The
|
||||||
|
prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp`
|
||||||
|
(the 15 MB `ubuntu-x64` release asset) and is symlinked as
|
||||||
|
`/usr/local/bin/llama-server`, which is what makes **discovery find it over
|
||||||
|
ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets.
|
||||||
|
It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is
|
||||||
|
needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`,
|
||||||
|
639 MB under `~/.local/share/ai-app/models` — and 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.
|
||||||
|
|
||||||
|
There is no second machine, so **ssh this VM to itself**. That is set up
|
||||||
|
too: the key is `~/.config/ai-app/ssh-self` (its public half is in
|
||||||
|
`~/.ssh/authorized_keys`, labelled removable), and the real config carries a
|
||||||
|
setup called **"this vm over ssh"** — `bob@127.0.0.1` with that
|
||||||
|
`identityFile` plus
|
||||||
|
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
|
||||||
|
so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the
|
||||||
|
whole rig for "does a remote llama session work", since the far machine is
|
||||||
|
this one and the model file is the same file. For a throwaway setup of your
|
||||||
|
own, 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. 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.
|
||||||
|
|
||||||
|
## 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 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.
|
||||||
|
|
||||||
|
## Measurements worth not re-taking
|
||||||
|
|
||||||
|
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
|
||||||
|
emulator against a real imported transcript with the server at
|
||||||
|
`--delay 120`. Settled and flinging fast, both into fresh history and back
|
||||||
|
through rows already drawn: **5.2–5.9% janky frames, 99th percentile
|
||||||
|
29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same
|
||||||
|
device is 3.3% and 38ms, so this is at the platform floor. The number that
|
||||||
|
is *not* at the floor is the first few seconds after opening a session,
|
||||||
|
where every row on the way is being composed for the first time; that is
|
||||||
|
inherent to a lazy list and it is why a measurement taken before the screen
|
||||||
|
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
|
||||||
|
- **The reset path is not reachable by reopening a session.** Measured
|
||||||
|
2026-09-04 against a session streaming at 20 events a second: reopening one
|
||||||
|
with an anchor 1,800 events back connects **87–119 events behind**, well
|
||||||
|
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
|
||||||
|
opening page, then one span covering the whole distance. To exercise the
|
||||||
|
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
|
||||||
|
the app takes the reset on a live connection, clears, refills and carries
|
||||||
|
on without reconnecting.
|
||||||
|
- **The session screen's stream survives backgrounding here** — 20 seconds at
|
||||||
|
the launcher while 415 events were produced brought no reconnect at all,
|
||||||
|
which is not what the comment above that loop expects, and is most likely
|
||||||
|
this emulator being headless rather than the phone's behaviour.
|
||||||
|
- **Reopening a cached session costs one request for one event** (the probe),
|
||||||
|
and scrolling the whole conversation back costs nothing more; a cold open
|
||||||
|
of the same 500-event session is two pages, 100 events. Measured
|
||||||
|
2026-09-04 on the emulator against the sandbox.
|
||||||
|
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
|
||||||
|
28,000-line file because it draws one row per line; the editor is one
|
||||||
|
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
|
||||||
|
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
|
||||||
|
screen. If you make the editor faster, that number is what to move.
|
||||||
|
EXPLORER.md's "What the measurements said" has the rest.
|
||||||
@@ -55,4 +55,21 @@ components: [
|
|||||||
// the terminal the QR would be printed on.
|
// the terminal the QR would be printed on.
|
||||||
enroll: "server/enroll-link.sh",
|
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 (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
|
||||||
|
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
|
||||||
|
// both need.
|
||||||
|
Apk(
|
||||||
|
name: "shell",
|
||||||
|
modes: ["release", "debug"],
|
||||||
|
build: "cargo xtask apk",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
+16
@@ -1,12 +1,20 @@
|
|||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
app/androidApp/build/
|
app/androidApp/build/
|
||||||
|
app/shellApp/build/
|
||||||
local.properties
|
local.properties
|
||||||
.kotlin/
|
.kotlin/
|
||||||
*.iml
|
*.iml
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
server/target/
|
server/target/
|
||||||
|
event-model/target/
|
||||||
|
client-core/target/
|
||||||
|
android-shell/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/
|
||||||
|
|
||||||
# Server logs from a development run (ai-server.log by convention,
|
# Server logs from a development run (ai-server.log by convention,
|
||||||
# wg-test.log from ./test-wg-tunnel.sh).
|
# wg-test.log from ./test-wg-tunnel.sh).
|
||||||
@@ -24,3 +32,11 @@ sessions/
|
|||||||
|
|
||||||
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
||||||
iris/target/
|
iris/target/
|
||||||
|
iris/android-app/target/
|
||||||
|
|
||||||
|
# E5's packaging xtask (RUST.md). `build/` above already covers
|
||||||
|
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
|
||||||
|
# The repo root has no Cargo workspace, so this is xtask's own
|
||||||
|
# intermediate working files (target/xtask/apk/...), not a shared one.
|
||||||
|
xtask/target/
|
||||||
|
/target/
|
||||||
@@ -5,11 +5,14 @@ replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
|||||||
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
||||||
between them.
|
between them.
|
||||||
|
|
||||||
**`PLAN.md` is the design source of truth** — every decision with its date,
|
**`docs/PLAN.md` is the design source of truth** — every decision with its
|
||||||
its rationale, and what was rejected. Read it before changing anything
|
date, its rationale, and what was rejected. Read it before changing anything
|
||||||
structural, and update it in place when a decision changes rather than
|
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
|
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 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.
|
||||||
|
|
||||||
The central design point, worth not undoing by accident: **a session is a
|
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
|
child process, translated into one common event model.** A new session type
|
||||||
@@ -22,7 +25,7 @@ Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
|||||||
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
|
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
|
||||||
`:androidApp` module), same cert scheme, same registry pattern. Read
|
`:androidApp` module), same cert scheme, same registry pattern. Read
|
||||||
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
|
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
|
||||||
Module-by-module intent is in PLAN.md's "Backend layout".
|
Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
|
||||||
|
|
||||||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
||||||
comment is the HTTP table and the surface's source of truth.
|
comment is the HTTP table and the surface's source of truth.
|
||||||
@@ -40,16 +43,23 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
|||||||
projects version-locked to the commit this repo pins. What deliberately did
|
projects version-locked to the commit this repo pins. What deliberately did
|
||||||
**not** move is the API surface and the config *schema*: routes, drivers,
|
**not** move is the API surface and the config *schema*: routes, drivers,
|
||||||
sessions and setups are what makes this project itself.
|
sessions and setups are what makes this project itself.
|
||||||
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
|
- `docs/` — every design and working document except this file and
|
||||||
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
`CLAUDE.md`:
|
||||||
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
|
- `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs`
|
||||||
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
|
and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||||||
and stream effects in `SessionScreen.kt`.
|
- `docs/TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent.
|
||||||
- `TODO.md` — the working list.
|
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
|
||||||
- `RUST.md` — the plan for moving the app to Rust (on the `rustify`
|
the opening and stream effects in `SessionScreen.kt`.
|
||||||
|
- `docs/TODO.md` — the working list.
|
||||||
|
- `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
|
branch of the `ai-app-2` clone): what has to be reproduced, the
|
||||||
framework decision, and the ordered experiments with their pass
|
framework decision, and the ordered experiments with their pass
|
||||||
conditions. Read it before touching anything under that branch.
|
conditions. Read it before touching anything under that branch.
|
||||||
|
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`,
|
||||||
|
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's
|
||||||
|
own public API log, working list, decisions log, layout/render design,
|
||||||
|
and texture-atlas design, and the client-core crate's design,
|
||||||
|
respectively.
|
||||||
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
||||||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
||||||
rather than a script kept here) and the APK, in parallel. It points at
|
rather than a script kept here) and the APK, in parallel. It points at
|
||||||
@@ -86,7 +96,11 @@ two icon buttons the same width without either being given one — and why
|
|||||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||||||
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
||||||
syntax highlighter, the ANSI parser and the transcript cache — the app's
|
syntax highlighter, the ANSI parser and the transcript cache — the app's
|
||||||
pure logic with no Android in it.
|
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
|
- **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
|
crash that had been shipping (`java.time` on a minSdk-24 app with
|
||||||
desugaring off) and later a permission check that silently dropped every
|
desugaring off) and later a permission check that silently dropped every
|
||||||
@@ -146,6 +160,27 @@ two icon buttons the same width without either being given one — and why
|
|||||||
|
|
||||||
Each exists because something was invisible without it.
|
Each exists because something was invisible without it.
|
||||||
|
|
||||||
|
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
|
||||||
|
and DECISIONS.md's 2026-09-05 entries), 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
|
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
|
||||||
and data directory, holding eight invented Claude Code transcripts and a
|
and data directory, holding eight invented Claude Code transcripts and a
|
||||||
`claude` that is two lines of shell. **That isolation is the point**: the
|
`claude` that is two lines of shell. **That isolation is the point**: the
|
||||||
@@ -226,6 +261,13 @@ Each exists because something was invisible without it.
|
|||||||
framework, from `atrace` text output with no trace processor needed. It is
|
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
|
how the cost of a layout node per link was attributed to the framework
|
||||||
rather than guessed at.
|
rather than guessed at.
|
||||||
|
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features
|
||||||
|
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK
|
||||||
|
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and
|
||||||
|
**`iris/android-app/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).
|
||||||
|
|
||||||
### Driving the UI
|
### Driving the UI
|
||||||
|
|
||||||
@@ -312,7 +354,7 @@ means here:
|
|||||||
## Sessions outlive the backend
|
## Sessions outlive the backend
|
||||||
|
|
||||||
Since 2026-08-29 a session's process is deliberately left running when
|
Since 2026-08-29 a session's process is deliberately left running when
|
||||||
`ai-server` stops, and adopted again when it starts. PLAN.md has the design;
|
`ai-server` stops, and adopted again when it starts. docs/PLAN.md has the design;
|
||||||
day to day:
|
day to day:
|
||||||
|
|
||||||
- **Stopping the server no longer stops the sessions.** After `pkill
|
- **Stopping the server no longer stops the sessions.** After `pkill
|
||||||
@@ -345,7 +387,7 @@ 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.
|
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
|
**Never import a Claude Code session that is open in a terminal.** The app
|
||||||
refuses it — see PLAN.md for the incident that made that a refusal rather
|
refuses it — see docs/PLAN.md for the incident that made that a refusal rather
|
||||||
than a warning.
|
than a warning.
|
||||||
|
|
||||||
**One Claude Code session id can name two files, and the listing offers it
|
**One Claude Code session id can name two files, and the listing offers it
|
||||||
@@ -521,4 +563,4 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
|||||||
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
|
`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
|
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.
|
screen. If you make the editor faster, that number is what to move.
|
||||||
EXPLORER.md's "What the measurements said" has the rest.
|
docs/EXPLORER.md's "What the measurements said" has the rest.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Decisions awaiting review
|
||||||
|
|
||||||
|
Choices made while working autonomously, for Bryan to keep or change. Each
|
||||||
|
says what was picked and why; the detail is in the design doc it names.
|
||||||
|
Delete an entry once it has been looked at.
|
||||||
|
|
||||||
|
## Subagent views (2026-09-05, `SUBAGENTS.md`)
|
||||||
|
|
||||||
|
Made on my own judgement, limited blast radius:
|
||||||
|
|
||||||
|
1. **A subagent is a transcript, not a session.** It has no process,
|
||||||
|
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
|
||||||
|
and stored under the session's directory, so deleting the session takes
|
||||||
|
it. Alternative rejected: registering it as a session of its own, which
|
||||||
|
would give it a card in the main list and a driver that can do nothing.
|
||||||
|
2. **Read-only view is the session screen minus its controls**, rather than
|
||||||
|
a second, simpler transcript screen. Keeps paging, caching, selection
|
||||||
|
and rendering in one place. Cost: a `readOnly` mode threaded through
|
||||||
|
`SessionScreen`.
|
||||||
|
3. **The list only carries a count.** Each session row says how many
|
||||||
|
subagents it has; their titles and statuses are fetched when the card is
|
||||||
|
expanded. Keeps `GET /sessions` from reading every subagent transcript.
|
||||||
|
Consequence: an expanded card's statuses refresh with the list, not live.
|
||||||
|
4. **Expanded/collapsed is remembered per session on the phone**, not on
|
||||||
|
the server. Collapsed by default, per the transcript convention that new
|
||||||
|
things arrive collapsed.
|
||||||
|
5. **Subagents of imported sessions are not shown.** The import path still
|
||||||
|
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
|
||||||
|
are not read. Only subagents run while this backend was watching exist.
|
||||||
|
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
|
||||||
|
paid turn to exercise.
|
||||||
|
|
||||||
|
Deferred, because they reach further than this feature:
|
||||||
|
|
||||||
|
- **Live status on the list.** Whether the session list should follow a
|
||||||
|
stream at all (it refreshes on demand today) decides whether subagent
|
||||||
|
status can ever be live there. Not changed.
|
||||||
|
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
|
||||||
|
in its transcript and are not given transcripts of their own. Supporting
|
||||||
|
that is the same mechanism one level down, but the UI would need nested
|
||||||
|
expanders.
|
||||||
|
|
||||||
|
- **The subagent status row says "context unknown".** Nothing measures a
|
||||||
|
subagent's context; the row could leave it out rather than admit it.
|
||||||
@@ -1,707 +0,0 @@
|
|||||||
# Moving the app to Rust
|
|
||||||
|
|
||||||
Working document for the question Iris asked on 2026-09-04: what are the
|
|
||||||
options for switching the phone app to Rust, ideally pure Rust with one UI
|
|
||||||
framework shared with a future winit-based desktop application, at full
|
|
||||||
feature parity and without giving up anything native, performance
|
|
||||||
especially. Constraints she set: no Dioxus and nothing that draws through a
|
|
||||||
WebView; **no UI DSL** (which rules out Makepad and Slint); the result
|
|
||||||
should stay lightweight; platform-specific pieces are fine to maintain;
|
|
||||||
reimplementing a framework piece from scratch where it does not fit is
|
|
||||||
fine; effort and elapsed time do not matter, long-term robustness does;
|
|
||||||
this clone is where things get tried before anything is committed to
|
|
||||||
`ai-app`. Her own library, [iris](https://github.com/cat16/iris), is the
|
|
||||||
**in-house framework to be built up** for this, with Masonry as the
|
|
||||||
yardstick it is measured against.
|
|
||||||
|
|
||||||
Decisions get a date and a reason here, the way `PLAN.md` does. Nothing in
|
|
||||||
this file has been tried yet unless a section says it has.
|
|
||||||
|
|
||||||
## What has to be reproduced
|
|
||||||
|
|
||||||
The app is ~19,000 lines of Kotlin. It splits three ways, and the split is
|
|
||||||
what decides how much of a port is mechanical.
|
|
||||||
|
|
||||||
**Pure logic with no Compose or Android in it, ~4,500 lines.** `Api.kt`
|
|
||||||
(1,142), `Events.kt`, `EventStream.kt`, `Sse.kt`, `TranscriptCache.kt`
|
|
||||||
(589, touches `java.io.File` only), `TranscriptSource.kt`,
|
|
||||||
`MarkdownSyntax.kt`, `Languages.kt`, `Highlighter.kt`, `Ansi.kt`,
|
|
||||||
`ResetCountdown.kt`, `Durations.kt`, `Sizes.kt`, `ModelName.kt`,
|
|
||||||
`LoadState.kt`, `ImportableStream.kt`. `TranscriptUnits.kt` and
|
|
||||||
`TranscriptItems.kt` (the event fold into rows, ~940 lines) are logic with
|
|
||||||
a handful of Compose annotations. This is also exactly the code that has
|
|
||||||
JVM unit tests today. All of it ports directly, and most of it already has a
|
|
||||||
Rust twin in `server/`: `Events.kt` is a hand-kept mirror of
|
|
||||||
`session/driver.rs`'s enum, the highlighter and the syntax scanner exist on
|
|
||||||
the server for the explorer, and the cache compares the server's own JSON
|
|
||||||
lines. **Sharing these types between server and app is the single largest
|
|
||||||
"keep things in sync" win available, and it does not depend on which UI
|
|
||||||
framework wins.**
|
|
||||||
|
|
||||||
**Compose UI, ~13,000 lines.** Screens, dialogs, the transcript list, the
|
|
||||||
markdown renderer's customisations, tool cards, the file explorer viewer and
|
|
||||||
editor. This is the part a UI framework choice is about.
|
|
||||||
|
|
||||||
**Android platform code, ~1,500 lines**, spread over 20 files. Every one of
|
|
||||||
these is a Java-side object that no Rust framework can replace, because
|
|
||||||
Android only offers them as Java classes:
|
|
||||||
|
|
||||||
- `NotificationService` — a **foreground service** holding the
|
|
||||||
`/notifications` SSE stream while the app is closed, with its ongoing
|
|
||||||
notification, `specialUse` type and the `POST_NOTIFICATIONS` request.
|
|
||||||
- `MainActivity` — edge-to-edge, the `ACCESS_LOCAL_NETWORK` runtime
|
|
||||||
permission (Android 17), `singleTop` intent routing for `aiapp://enroll`,
|
|
||||||
notification taps, and the **share sheet** (`ACTION_SEND`, any MIME type).
|
|
||||||
- `ServerConfig` — the bearer token sealed under an **Android Keystore**
|
|
||||||
AES-GCM key, shared with Dev Updater through `wg-app-link`'s `:link`.
|
|
||||||
- `EnrollmentScanActivity` — the in-app **QR scanner** (zxing, camera).
|
|
||||||
- `Attachments` — `ContentResolver` reads of shared URIs, `BitmapFactory`
|
|
||||||
decode and downscale, **EXIF** orientation.
|
|
||||||
- `SessionImage` — bitmap decode for produced images.
|
|
||||||
- `ScrollAnchor`, `Drafts` — `SharedPreferences`; `CrashLog` — `filesDir`.
|
|
||||||
- `TranscriptCache` — `cacheDir`.
|
|
||||||
- `DebugStats`/`FrameStats` — `Choreographer` frame timing and the render
|
|
||||||
report; `runtime-tracing` names composables in a system trace.
|
|
||||||
|
|
||||||
So **"pure Rust" on Android means Rust owns every line of logic and
|
|
||||||
drawing, behind a thin shell of Java stubs**, and a packaging step that
|
|
||||||
produces a signed APK. How thin, and whether Gradle is inevitable, are
|
|
||||||
answered below.
|
|
||||||
|
|
||||||
### How much Java is unavoidable, and why
|
|
||||||
|
|
||||||
Rust can *call* any Android API through JNI (`jni` crate, with
|
|
||||||
`ndk-context` handing over the `JavaVM` and the Activity): posting a
|
|
||||||
notification, `startForegroundService`, the Keystore, `ContentResolver`
|
|
||||||
reads, permission requests, `WindowInsets`, the clipboard. None of that
|
|
||||||
needs a line of Kotlin. What JNI cannot do is *define* a class that the
|
|
||||||
system instantiates **by name from the manifest** — an `Activity`, a
|
|
||||||
`Service`, an `Application`, a `BroadcastReceiver`. Those must exist as dex
|
|
||||||
bytecode inside the APK before any Rust runs, because the framework
|
|
||||||
constructs them and only then calls into native code. `NativeActivity` is
|
|
||||||
the platform's own stub for the Activity case; there is no
|
|
||||||
`NativeService`, and android-view ships its own `View` subclass for the
|
|
||||||
same reason.
|
|
||||||
|
|
||||||
So the floor is roughly **two Java classes of ten lines each**: an
|
|
||||||
`Activity` and a `Service` whose lifecycle methods are declared `native`
|
|
||||||
and registered from `JNI_OnLoad`, plus whatever android-view already
|
|
||||||
provides. Everything they would have done in Kotlin — insets, intent
|
|
||||||
routing, the SSE follow loop, the notification builder — is Rust reached
|
|
||||||
through those stubs. Writing the stubs in Java rather than Kotlin drops
|
|
||||||
`kotlinc` from the toolchain; `javac` comes with the JDK Gradle already
|
|
||||||
needs. Generating the dex from Rust is not worth it: there is no mature
|
|
||||||
Rust dex writer, and the stubs never change.
|
|
||||||
|
|
||||||
### Can the APK be built without Gradle?
|
|
||||||
|
|
||||||
Yes. An APK is a zip containing a binary-XML `AndroidManifest.xml`,
|
|
||||||
`resources.arsc`, `classes.dex`, `lib/<abi>/*.so` and assets, aligned and
|
|
||||||
signed with the v2 scheme. The tools are `aapt2` (manifest and resources),
|
|
||||||
`d8` (Java bytecode to dex), `zipalign` and `apksigner`, all in the SDK's
|
|
||||||
`build-tools`, none of them Gradle. Three ways to drive them:
|
|
||||||
|
|
||||||
- **A `cargo xtask`** (or `build.rs`-adjacent script) that runs `cargo ndk`
|
|
||||||
for each ABI, `javac` + `d8` for the stubs, `aapt2 link`, `zipalign`,
|
|
||||||
`apksigner`. About 150 lines, every step visible, no AGP, no Gradle
|
|
||||||
daemon holding 2.8 GB between builds. The pinned-CA constant becomes a
|
|
||||||
`build.rs` reading the same `certs/ca.pem` path.
|
|
||||||
- **[cargo-apk2](https://github.com/mzdk100/cargo-apk2)**: the maintained
|
|
||||||
successor to cargo-apk, and unlike it compiles `java_sources` /
|
|
||||||
`kotlin_sources` into the dex and declares multiple activities **and
|
|
||||||
services** with intent filters from `[package.metadata.android]`, with
|
|
||||||
per-profile keystores and optional `aapt2`. Exactly the shape needed;
|
|
||||||
the question is whether a third-party tool with one maintainer beats
|
|
||||||
150 lines we own.
|
|
||||||
- **cargo-apk / xbuild**: unmaintained and `NativeActivity`-only. No.
|
|
||||||
|
|
||||||
What Gradle would take with it: Android Lint (which found two real bugs
|
|
||||||
here, but in Kotlin that would no longer exist — with forty lines of Java
|
|
||||||
stubs there is little left for it to find), manifest merging, R8, and the
|
|
||||||
generated-source plumbing. What it gives back: one toolchain, `cargo`
|
|
||||||
end to end, and Dev Updater keeps calling `build-apk.sh` exactly as now.
|
|
||||||
**Recommendation: the xtask**, with cargo-apk2 read for the details it
|
|
||||||
already got right (v2 signing, `uses-feature`, ABI splits).
|
|
||||||
|
|
||||||
### The behaviours that are hard to get back
|
|
||||||
|
|
||||||
Reading the Compose code for what a replacement must be able to express,
|
|
||||||
rather than what it happens to look like:
|
|
||||||
|
|
||||||
1. **The transcript is one selectable body of text.** One
|
|
||||||
`SelectionContainer` around the whole lazy list, so a selection runs from
|
|
||||||
a reply into the tool output beneath it. The framework needs selectable
|
|
||||||
read-only rich text across many rows, with the platform's selection
|
|
||||||
handles and clipboard on the phone.
|
|
||||||
2. **Rich inline text**: markdown with links (one tap detector per text,
|
|
||||||
not a node per link), inline code chips drawn behind the text, tables
|
|
||||||
with wrapping cells and a sideways scroll, syntax-highlighted fences,
|
|
||||||
ANSI colour in tool output, Nerd Font icon glyphs. Needs a text layout
|
|
||||||
engine with spans, not just styled labels.
|
|
||||||
3. **A bottom-anchored virtualised list of variable-height rows**, paged in
|
|
||||||
both directions (800-event pages, `HISTORY_SCREENS` measured in
|
|
||||||
viewports), with a saved scroll anchor per session, "hold the edge
|
|
||||||
nearest the tap" when a row expands (`holdTopEdge`, done in the layout
|
|
||||||
pass so the wrong frame is never drawn), and rows keyed so that a run of
|
|
||||||
tool calls stays one row while it grows.
|
|
||||||
4. **The soft keyboard**: the composer resizes with the IME, the guard
|
|
||||||
against a stuck inset animation, drafts per session, autocorrect and
|
|
||||||
suggestions from the phone's own keyboard. This is where most Rust
|
|
||||||
frameworks fail on Android today; see below.
|
|
||||||
5. **Platform integration through the app model**: foreground service,
|
|
||||||
notifications, share sheet, deep link, Keystore, camera, back gesture,
|
|
||||||
edge-to-edge insets, local-network permission.
|
|
||||||
6. **Accessibility names on icon buttons**, which the bench scripts depend
|
|
||||||
on (`ui-trace` taps by label). A framework with no accessibility tree
|
|
||||||
also breaks the measuring rig.
|
|
||||||
7. **Measurable frames**: the debug render report, and a way to attribute
|
|
||||||
a frame's cost to a widget on the real phone.
|
|
||||||
|
|
||||||
## The two constraints that decide it
|
|
||||||
|
|
||||||
**1. Android text input.** Every framework built on `winit` inherits
|
|
||||||
winit's Android backend, and that backend cannot drive the soft keyboard
|
|
||||||
properly: the IME tracking issues
|
|
||||||
([#1823](https://github.com/rust-windowing/winit/issues/1823),
|
|
||||||
[#2766](https://github.com/rust-windowing/winit/issues/2766)) are open,
|
|
||||||
`ReceivedCharacter` is unimplemented on Android
|
|
||||||
([#2305](https://github.com/rust-windowing/winit/issues/2305)), and the
|
|
||||||
`android-activity` groundwork for editor actions only merged in February
|
|
||||||
2026 ([PR #214](https://github.com/rust-mobile/android-activity/pull/214))
|
|
||||||
with the winit half still to come. Composition, autocorrect and suggestions
|
|
||||||
need an `InputConnection` implemented on the Java side, which winit's
|
|
||||||
`NativeActivity`/`GameActivity` model does not offer. The frameworks that
|
|
||||||
type on Android today each wrote their own Java glue (Slint, Makepad), and
|
|
||||||
the one designed to do it the way Android intends is
|
|
||||||
[`android-view`](https://github.com/rust-mobile/android-view): a Rust
|
|
||||||
implementation of an Android `View`, with text input through
|
|
||||||
`InputConnection`, accessibility, touch, callbacks on the UI thread, usable
|
|
||||||
either as a whole app or embedded beside ordinary Android components. It is
|
|
||||||
marked WIP. Both Linebender (its Masonry demo lives in that repo) and
|
|
||||||
Robius/Makepad ([Robrix's release notes](https://github.com/project-robius/robrix/releases)
|
|
||||||
say Android lacks a "full" keyboard and they are integrating android-view
|
|
||||||
for it) are converging on it. **That makes android-view the phone-side
|
|
||||||
foundation whichever widget set sits on top**, and the first thing to
|
|
||||||
build and measure here.
|
|
||||||
|
|
||||||
**2. Rich, selectable text and a virtualised list.** Frameworks group by
|
|
||||||
their text stack:
|
|
||||||
|
|
||||||
- **Parley + Fontique + Vello** (Linebender): rich spans, selection and
|
|
||||||
editing utilities, IME support driven through `ui-events`, AccessKit text
|
|
||||||
properties ([Linebender 2026 Q1](https://linebender.org/blog/tmil-25/),
|
|
||||||
[parley](https://github.com/linebender/parley)). Used by Masonry/Xilem,
|
|
||||||
and by Blitz. Vello proper needs compute shaders; `vello_hybrid` (CPU
|
|
||||||
path processing, GPU compositing) is "roughly beta" and runs on GLES too,
|
|
||||||
and Vello CPU exists as a no-GPU fallback.
|
|
||||||
- **cosmic-text** (iced, egui optionally): good layout, but the widgets on
|
|
||||||
top decide selection. iced's `markdown` widget is not selectable
|
|
||||||
([discourse](https://discourse.iced.rs/t/markdown-widgets-text-should-be-selectable/1107)).
|
|
||||||
- **Slint's own**: `TextInput` with `read-only` is the selectable-text
|
|
||||||
trick; there is **no inline rich text at all** (issue
|
|
||||||
[#1325](https://github.com/slint-ui/slint/issues/1325), markdown request
|
|
||||||
[#6684](https://github.com/slint-ui/slint/issues/6684) both open). A
|
|
||||||
markdown transcript with links and code chips cannot be drawn.
|
|
||||||
- **Makepad's own**: GPU/SDF text, a `Markdown` widget and a virtualised
|
|
||||||
`PortalList` in `makepad-widgets`.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
### A. Keep Compose, move the logic into a Rust core (uniffi)
|
|
||||||
|
|
||||||
A `client-core` crate (events shared with the server, API client, SSE,
|
|
||||||
transcript fold, cache, markdown model, highlighter, ANSI) exposed to
|
|
||||||
Kotlin through [uniffi](https://github.com/mozilla/uniffi-rs). Compose keeps
|
|
||||||
drawing. Desktop would be a second UI (iced or Compose Desktop) over the
|
|
||||||
same core.
|
|
||||||
|
|
||||||
- **For**: the logic and the wire types stop drifting from the server
|
|
||||||
today, with tests in one language. Incremental and always shippable.
|
|
||||||
- **Against**: it is not what was asked for. The 13,000 lines of UI stay
|
|
||||||
Kotlin, the desktop app shares no UI code, and the `:link` Kotlin module
|
|
||||||
stays. uniffi's Kotlin Multiplatform bindings are a
|
|
||||||
[community fork](https://github.com/UbiqueInnovation/uniffi-kotlin-multiplatform-bindings);
|
|
||||||
the Android-only bindings are Mozilla's and solid.
|
|
||||||
- **Verdict**: not the destination, but **step one of every other option**
|
|
||||||
is building this crate, so it costs nothing to keep it as the fallback.
|
|
||||||
|
|
||||||
### B. Slint
|
|
||||||
|
|
||||||
Rust on Android is officially supported (minSdk 26, `android-activity`
|
|
||||||
backend, own Java IME glue, safe areas and keyboard insets since 1.15,
|
|
||||||
Skia renderer needs `clang`). Royalty-free licence requires disclosing
|
|
||||||
Slint use; GPLv3 otherwise. UI is a separate `.slint` DSL, not Rust.
|
|
||||||
|
|
||||||
- **Against**: no rich inline text (see above), so the transcript cannot be
|
|
||||||
drawn as it is today; the UI language is not Rust, which forfeits the
|
|
||||||
"compiler catches it" motivation for the half of the code that is UI.
|
|
||||||
- **Verdict**: rejected on rich text alone.
|
|
||||||
|
|
||||||
### C. iced
|
|
||||||
|
|
||||||
Elm-style, Rust-only widgets, desktop-first, `winit` + `wgpu`. Has a
|
|
||||||
`markdown` widget and `rich_text` with links. The maintainer states mobile
|
|
||||||
is a non-goal ([iced](https://github.com/iced-rs/iced)); a community
|
|
||||||
Android example exists and its author could not get the soft keyboard
|
|
||||||
working, patched widgets for touch, and notes no accessibility
|
|
||||||
([HN thread](https://news.ycombinator.com/item?id=46350641)). Markdown is
|
|
||||||
not selectable; `scrollable` is not virtualised.
|
|
||||||
|
|
||||||
- **Verdict**: a fine desktop toolkit and the one Iris named, but every
|
|
||||||
phone-side gap (IME, touch, accessibility, selection, virtualisation)
|
|
||||||
would be ours to build and maintain against a project that does not want
|
|
||||||
them. Not the shared framework.
|
|
||||||
|
|
||||||
### D. egui
|
|
||||||
|
|
||||||
Immediate mode, `winit`-based on Android, AccessKit integration,
|
|
||||||
selectable labels across a `Ui`. Repaints only on input by default, so
|
|
||||||
battery is not the immediate-mode worry. Android IME is blocked on winit
|
|
||||||
([discussion](https://github.com/emilk/egui/discussions/2053)); the
|
|
||||||
workaround is an in-app virtual keyboard, which is exactly the
|
|
||||||
non-native keyboard to avoid. Variable-height virtualised lists are manual
|
|
||||||
(`show_rows` assumes uniform heights). Looks like egui, not Material.
|
|
||||||
|
|
||||||
- **Verdict**: workable on desktop, wrong on the phone for the same reason
|
|
||||||
as iced, plus a look that would need a full custom style.
|
|
||||||
|
|
||||||
### E. Makepad
|
|
||||||
|
|
||||||
GPU-rendered, hybrid retained/immediate, `live_design!` DSL with hot
|
|
||||||
reload, MIT, 1.0 in 2025 ([makepad](https://github.com/makepad/makepad)).
|
|
||||||
Ships Android apps today with its own Java glue; Robrix (a Matrix chat
|
|
||||||
client, the closest analogue to this app) is its reference application on
|
|
||||||
Android, iOS and desktop. Has `Markdown`, `PortalList` (virtualised),
|
|
||||||
`TextInput`. Robrix reports the Android keyboard is not "full" and is moving
|
|
||||||
to android-view for it; the README says non-standard targets "may require
|
|
||||||
minor fixes".
|
|
||||||
|
|
||||||
- **For**: the only option that already ships a chat-shaped app on Android
|
|
||||||
and desktop from one codebase, with the widgets this app needs.
|
|
||||||
- **Against**: the DSL is its own language with its own shader-based
|
|
||||||
styling, so a large part of the UI would not be checked by rustc; the
|
|
||||||
rendering model (SDF everything) is a different world from Compose's,
|
|
||||||
and selection across a `Markdown` widget is unverified.
|
|
||||||
- **Verdict**: **rejected 2026-09-04** — Iris does not want a DSL. Kept
|
|
||||||
here so its Android keyboard status stays a data point about
|
|
||||||
android-view, not as an option.
|
|
||||||
|
|
||||||
### F. Masonry / Xilem on android-view (Linebender)
|
|
||||||
|
|
||||||
Retained widget tree (Masonry) with a reactive view layer (Xilem) that
|
|
||||||
reads like Compose; Rust all the way down; Vello, Parley, Fontique,
|
|
||||||
AccessKit, `ui-events`. Widgets include `Prose` (selectable read-only rich
|
|
||||||
text), `TextArea`, `VirtualScroll`, and this year `Svg`, `Split`,
|
|
||||||
`CollapsePanel`, a new layout system, and IME through `ui-events`
|
|
||||||
independent of winit. `masonry_android_view` exists in the android-view
|
|
||||||
repo and is "not yet generally usable"; Xilem calls itself experimental.
|
|
||||||
Desktop runs on winit. Vello needs a compute-capable GPU or falls back to
|
|
||||||
`vello_hybrid`/CPU.
|
|
||||||
|
|
||||||
- **For**: the only stack where every hard behaviour above maps onto a
|
|
||||||
component designed for it: selection and rich text (Parley/Prose),
|
|
||||||
virtualised variable heights (`VirtualScroll`), native IME
|
|
||||||
(android-view's `InputConnection`), accessibility (AccessKit, now with an
|
|
||||||
Android crate), one Rust widget language on both platforms. The team is
|
|
||||||
the one writing the Android integration everyone else is adopting.
|
|
||||||
- **Against**: pre-1.0 with API churn each release; a small team; no
|
|
||||||
Material widget set, so every control's look is ours; some of the pieces
|
|
||||||
(`masonry_android_view`, `vello_hybrid`) are explicitly unfinished. Being
|
|
||||||
early means fixing things upstream ourselves, which Iris said is
|
|
||||||
acceptable.
|
|
||||||
- **Verdict**: **the option to try first**, because it is the only one
|
|
||||||
whose gaps are "not finished yet" rather than "not designed for this".
|
|
||||||
|
|
||||||
### G. iris — the in-house library, and what "from scratch" means here
|
|
||||||
|
|
||||||
[cat16/iris](https://github.com/cat16/iris), read 2026-09-04 from the one
|
|
||||||
public commit (2026-01-31, "portfolio copy"; ~8,700 lines in `core`,
|
|
||||||
`macro` and the crate itself). Retained-mode widgets stored outside the
|
|
||||||
render tree, `wgpu` 28 directly, `winit` 0.30, `cosmic-text` 0.16, a
|
|
||||||
relative-anchor-plus-offset layout with `rest()` and `rel()` lengths, a
|
|
||||||
postfix builder API (`rect(..).radius(30).on(CursorSense::click(), ..)
|
|
||||||
.sized(..).align(..)`), events handled where the widget is declared, and
|
|
||||||
a single-threaded context passed explicitly — all of which reads like this
|
|
||||||
codebase's own rules. There is text editing (`widget/text/edit.rs`),
|
|
||||||
images, masks, spans and stacks; the TODO names text resizing as
|
|
||||||
per-frame slow and scaling as unsolved. It requires **nightly** (fourteen
|
|
||||||
`#![feature]` gates, among them `const_trait_impl`, `unboxed_closures`,
|
|
||||||
`portable_simd`, `associated_type_defaults`). Desktop only; no Android
|
|
||||||
surface, no IME, no accessibility tree, no virtualised list, no rich-text
|
|
||||||
selection.
|
|
||||||
|
|
||||||
**iris is not a candidate to be tested as it stands. It is the in-house
|
|
||||||
library** (Iris, 2026-09-04): "essentially a good start to a rewrite from
|
|
||||||
scratch", to be maintained and extended by the sessions working here.
|
|
||||||
So the list above of what it lacks is a **work list, not a score**. When
|
|
||||||
the app needs something iris does not have, the answer is to build it
|
|
||||||
into iris. The layer iris has is the widget and layout layer; the layers
|
|
||||||
it needs are the same ones Masonry gets from android-view, Parley and
|
|
||||||
AccessKit, and there is no reason iris cannot sit on those same
|
|
||||||
foundations rather than reinvent them — the surface, the keyboard bridge
|
|
||||||
and the accessibility tree are platform plumbing, not a framework's
|
|
||||||
identity. Whether the text stack stays cosmic-text or moves to Parley is
|
|
||||||
the first real design decision in that work (see I1 below).
|
|
||||||
|
|
||||||
Two things to carry into that work honestly. Nightly is the opposite of
|
|
||||||
"holds up long term": a build that breaks on a toolchain update, on the
|
|
||||||
machine Dev Updater builds on, unattended. Pin a dated nightly in
|
|
||||||
`rust-toolchain.toml` immediately, and keep a list of which `#![feature]`
|
|
||||||
gates are load-bearing so they can be retired as they stabilise or are
|
|
||||||
designed around. And a one-person framework carries every gap itself,
|
|
||||||
which is what Iris said she is willing to do.
|
|
||||||
|
|
||||||
"From scratch" therefore means iris, not a fourth thing. Masonry stays in
|
|
||||||
the plan as the **yardstick and the fallback**: building its demo and its
|
|
||||||
version of the transcript screen first says what a finished stack costs
|
|
||||||
on this hardware, proves android-view before iris depends on it, and
|
|
||||||
gives a comparison that is measured rather than remembered.
|
|
||||||
|
|
||||||
Not considered further: **GPUI** (Zed) mobile is a community fork that
|
|
||||||
depends on unpublished crates; **Dioxus/Blitz** is excluded by Iris (its
|
|
||||||
native renderer is Parley/Vello under HTML semantics, and the earlier
|
|
||||||
`tdep-survey/app-dioxus` spike parked it on a `vello_hybrid` stroke bug and
|
|
||||||
shipped the WebView); **Compose Multiplatform Desktop** would give a desktop
|
|
||||||
app for nothing but in Kotlin, which is the opposite direction.
|
|
||||||
|
|
||||||
### Weight and debug builds
|
|
||||||
|
|
||||||
Iris remembers the Linebender stack being slow in debug. What is behind
|
|
||||||
that is the dependency graph — Vello, wgpu, Parley, Fontique, Skrifa —
|
|
||||||
running unoptimised on the CPU side (path encoding, shaping), not the
|
|
||||||
widget layer. Xilem's own advice is only `split-debuginfo = "unpacked"` to
|
|
||||||
keep `target/` small; the fix everyone with this shape of dependency tree
|
|
||||||
uses is to optimise dependencies while leaving the app crate at `opt-level
|
|
||||||
= 0`:
|
|
||||||
|
|
||||||
[profile.dev.package."*"]
|
|
||||||
opt-level = 2
|
|
||||||
|
|
||||||
E1 measures this rather than remembering it: cold and incremental build
|
|
||||||
time, APK size, resident memory at rest and while streaming, and the
|
|
||||||
frame cost of one 800-event page — for the Masonry demo as shipped, then
|
|
||||||
with the profile above. Vello proper needs compute shaders and carries a
|
|
||||||
large shader set; `vello_hybrid` is lighter and Masonry can now render
|
|
||||||
through either (or Vello CPU) via its `imaging` abstraction, so "keep it
|
|
||||||
light" has a knob inside the same stack.
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
1. **Build `client-core` now, whatever the framework.** A Rust crate holding
|
|
||||||
the event model (shared with `server/` as one crate, ending the
|
|
||||||
`Events.kt` mirror), the API and SSE clients, the transcript fold, the
|
|
||||||
cache, the markdown block model, the highlighter and the ANSI parser,
|
|
||||||
with the existing JVM tests ported. It is the part of the app that is
|
|
||||||
already tested, already logic, and already duplicated on the server.
|
|
||||||
2. **One foundation, two widget layers.** The platform plumbing is shared
|
|
||||||
whichever way the decision goes: android-view for the Android surface,
|
|
||||||
keyboard and accessibility bridge; `wgpu` for the GPU; AccessKit for
|
|
||||||
names; winit on the desktop. On top of it, **Masonry as the yardstick**
|
|
||||||
(E1, E2) and **iris as the thing being built** (I0–I5), both aimed at
|
|
||||||
the same transcript screen with the same pass conditions.
|
|
||||||
3. **Decide when the transcript screen exists in both**, from the
|
|
||||||
measurements, and record the decision here with the numbers. If iris
|
|
||||||
carries the screen within the Compose baseline, it is the app's
|
|
||||||
framework and Masonry was the calibration. If it does not, the
|
|
||||||
measurement says which parts of Masonry to adopt underneath it.
|
|
||||||
4. Then the shell (E3), the desktop window (E4) and the packaging (E5),
|
|
||||||
which do not depend on the choice.
|
|
||||||
|
|
||||||
## Experiments, in order
|
|
||||||
|
|
||||||
Each has a pass condition that is a measurement in this clone. The rig
|
|
||||||
matters: this emulator runs `-gpu host` with **host Vulkan switched off**
|
|
||||||
(`GPU_HOST_FEATURES` in `emulator-tools`, a gfxstream/Venus gap), so inside
|
|
||||||
the guest a `wgpu` app gets GLES, not Vulkan; the earlier Dioxus spike also
|
|
||||||
needed `WGPU_GLES_MINOR_VERSION=1` for compute shaders and found `wgpu`'s
|
|
||||||
Android backend wants API 26 (a libc symbol). The real phone has Vulkan.
|
|
||||||
Per the standing rule, a rig limit is something to fix before it is
|
|
||||||
accepted.
|
|
||||||
|
|
||||||
- [x] **E0 — toolchain (done 2026-09-04).** Installed under the
|
|
||||||
user-owned SDK: **NDK r29 (`29.0.14206865`)**, 2.4 GB at
|
|
||||||
`~/Android/Sdk/ndk/29.0.14206865`, the newest stable — r30 is still
|
|
||||||
at rc.3. **cargo-ndk 4.1.2**. Verified by cross-compiling a scratch
|
|
||||||
`cdylib` to both ABIs: `file` reports "for Android 26, built by NDK
|
|
||||||
r29 (14206865)" for `aarch64-linux-android` and
|
|
||||||
`x86_64-linux-android`. Two things to know at the call site.
|
|
||||||
**cargo-ndk 4's API-level flag is `-P`, not `-p`** — `-p` is now
|
|
||||||
passed through to cargo as `--package`, so the old
|
|
||||||
`cargo ndk -t arm64-v8a -p 26` panics with `unknown package: 26`
|
|
||||||
*and dumps the whole environment to stdout* as a bug report, which is
|
|
||||||
worth not doing in a log somebody might paste. And the Android
|
|
||||||
targets were installed for **stable** only; the pinned nightly needs
|
|
||||||
its own, which `iris/rust-toolchain.toml` now declares.
|
|
||||||
- [ ] **E1 — android-view's Masonry demo on this emulator.** Pass: it
|
|
||||||
builds with `cargo-ndk` + Gradle, renders on the GPU, and the phone's
|
|
||||||
own keyboard types into its editor with autocorrect and suggestions.
|
|
||||||
Note which `wgpu` backend it took and any environment flags needed.
|
|
||||||
- [ ] **E2 — a transcript in Masonry.** One screen: open a sandbox session,
|
|
||||||
page 800 events into `VirtualScroll` bottom-anchored, draw markdown
|
|
||||||
from `pulldown-cmark` into Parley rich text with links and code
|
|
||||||
chips, select across two rows with the platform handles, expand a
|
|
||||||
tool row holding its top edge. Pass: the render numbers land within
|
|
||||||
the Compose baseline in `transcript-bench.sh` on the GPU emulator
|
|
||||||
(same gestures, same session), and every one of the seven behaviours
|
|
||||||
above is either shown or has a written reason it cannot be.
|
|
||||||
- [ ] **E3 — the shell.** Kotlin `MainActivity` + `NotificationService`
|
|
||||||
+ Keystore + share intent calling into Rust over JNI, with the SSE
|
|
||||||
follow loop in Rust. Pass: a notification arrives with the app
|
|
||||||
closed, and a share lands in a session.
|
|
||||||
- [ ] **E4 — the same screen on the desktop** in a winit window, from the
|
|
||||||
same crate, with only the layout differing.
|
|
||||||
- [ ] **E5 — the packaging xtask**: `cargo ndk` → `javac`/`d8` → `aapt2`
|
|
||||||
→ `zipalign` → `apksigner`, signed with the existing release key,
|
|
||||||
installed through Dev Updater. Pass: the APK installs over the
|
|
||||||
Gradle-built one and the notification service starts.
|
|
||||||
|
|
||||||
### The iris track
|
|
||||||
|
|
||||||
These build iris up to carry the app. Each is a feature added to iris
|
|
||||||
with a pass condition, in dependency order. Work in `iris/` in this
|
|
||||||
repository on the `rustify` branch, and record in this file what each
|
|
||||||
step measured.
|
|
||||||
|
|
||||||
- [x] **I0a — where iris lives (decided 2026-09-04).** For now it is
|
|
||||||
**vendored at `iris/` in this repository**, history not carried,
|
|
||||||
and consumed by path. Iris's decision: keep it close while it is
|
|
||||||
being reshaped for this app, and give it back its own repository —
|
|
||||||
`iris/iris` on the gitea remote, which already holds the full
|
|
||||||
244-commit history, on a branch of its own — once it has proved
|
|
||||||
itself. The vendored tree is that repository's `main` at
|
|
||||||
`7b54aaf` ("readme", 2026-01-29), byte-identical to the public
|
|
||||||
GitHub copy, so a later reconciliation has a known base. A crate
|
|
||||||
that uses it says `iris = { path = "../iris" }`.
|
|
||||||
- [x] **I0b — make it build here (done 2026-09-04).** iris now builds,
|
|
||||||
clippy-clean and rustfmt-clean at the defaults, on a pinned dated
|
|
||||||
nightly, and the `tabs` example draws on this VM's GPU.
|
|
||||||
|
|
||||||
**The pin** is `nightly-2026-09-03` (rustc 1.100.0-nightly,
|
|
||||||
`2e2b193f8`), declared in `iris/rust-toolchain.toml` along with the
|
|
||||||
`clippy`/`rustfmt` components and the two Android targets, so a
|
|
||||||
fresh clone provisions itself. It is dated rather than `nightly`
|
|
||||||
because the whole failure below was a rolling channel moving under
|
|
||||||
an unattended build. Installed with `--profile minimal`: 912 MB.
|
|
||||||
|
|
||||||
**The 36 errors were one syntax change, and the earlier diagnosis in
|
|
||||||
this file was wrong.** It is not that a trait must now be declared
|
|
||||||
`const trait` — the vendored tree already declares them that way,
|
|
||||||
which is how it was written in January. What changed is the *impl*
|
|
||||||
keyword order: `impl const Trait for T` is now
|
|
||||||
`const impl Trait for T`, and generics go on the `impl`
|
|
||||||
(`const impl<T: [const] Foo> Bar for T`). Bounds are unaffected;
|
|
||||||
`T: const Foo`, `T: [const] Foo` and `impl const Foo` in argument
|
|
||||||
position all still compile. Everything else — the unresolved
|
|
||||||
`UiVec2`/`Vec2`/`impl_op` imports, and a `Color<u8>` that resolved
|
|
||||||
to `wgpu_types::Color` — cascaded from the seven files that failed
|
|
||||||
to parse. The rewrite was mechanical across 20 sites and took the
|
|
||||||
workspace from 36 errors to 0.
|
|
||||||
|
|
||||||
**`#![feature]` gates, 12 after this step** (two were declared and
|
|
||||||
unused, and were removed: `map_try_insert`, `const_cmp`).
|
|
||||||
Load-bearing and worth watching: `const_trait_impl`, `const_ops`,
|
|
||||||
`const_convert`, `const_destruct` are the const-traits family and
|
|
||||||
the one that has already broken once — they move together, so
|
|
||||||
advancing the pin means re-reading this section. `unboxed_closures`
|
|
||||||
+ `fn_traits` (postfix builder API) and `unsize` +
|
|
||||||
`coerce_unsized` (widget handles) are pairs. The rest are
|
|
||||||
individually small: `macro_metavar_expr_concat`, `portable_simd`,
|
|
||||||
`associated_type_defaults`, `option_into_flat_iter`, and `gen_blocks`
|
|
||||||
in the top crate.
|
|
||||||
|
|
||||||
**Running it headless.** `iris/run-headless.sh EXAMPLE [--shot PNG]`
|
|
||||||
with `iris/headless.conf`, the same trick `emu` uses: a headless
|
|
||||||
sway, and `grim` for the picture. It deliberately starts its *own*
|
|
||||||
compositor rather than joining `emu`'s — sway tiles, so adding a
|
|
||||||
window to the one an emulator sits in resizes that emulator.
|
|
||||||
Unlike `emu`'s it disables Xwayland, since winit speaks Wayland.
|
|
||||||
**This VM has a real GPU for this**: Vulkan 1.4 through Venus onto
|
|
||||||
the host's RX 7900 XT, and GL 4.6 through virgl — so desktop wgpu
|
|
||||||
work here is not software-rasterised, unlike inside the emulator.
|
|
||||||
|
|
||||||
**iris has no tests at all** (`cargo test --workspace`: 0 passed
|
|
||||||
across 6 targets). Nothing to keep passing, and nothing to catch a
|
|
||||||
regression — worth knowing before I1 changes the text stack.
|
|
||||||
|
|
||||||
**`iris-core` no longer depends on winit, and now cross-compiles to
|
|
||||||
Android.** It wanted exactly one thing from it — `PhysicalSize<u32>`
|
|
||||||
in `UiRenderNode::resize`'s signature, for two numbers it immediately
|
|
||||||
turned into floats — and that pulled a whole windowing backend into
|
|
||||||
the layer below it, the wrong direction. `resize` takes
|
|
||||||
`impl Into<Vec2>` now, like `UiRenderState::resize` beside it already
|
|
||||||
did. The consequence is the point: with winit in the graph an Android
|
|
||||||
build of the core failed in `android-activity` (which needs a backend
|
|
||||||
feature nothing here selects), and without it
|
|
||||||
`cargo ndk -t arm64-v8a -P 26 build -p iris-core` finishes in 30s and
|
|
||||||
produces an rlib, wgpu's Android backend included. So **iris's
|
|
||||||
widget, layout and render core already builds for the phone**, and
|
|
||||||
what I2 has to supply is the surface, the input and the IME — not a
|
|
||||||
port of the library.
|
|
||||||
|
|
||||||
**Build weight, cold, on this VM's 8 cores** (`rm -rf target`, then
|
|
||||||
`cargo build --example tabs`), since "the Linebender stack is slow in
|
|
||||||
debug" was the worry behind this question: plain debug **43s** and a
|
|
||||||
2.1 GB `target/`; with the `[profile.dev.package."*"] opt-level = 2`
|
|
||||||
knob, **1m46s** and 1.5 GB. So iris's own wgpu + winit + cosmic-text
|
|
||||||
graph is not the slow thing — which makes it a calibration for E1
|
|
||||||
rather than an answer about Masonry, whose graph adds Vello, Parley,
|
|
||||||
Fontique and Skrifa. Runtime cost of the knob was not measured here.
|
|
||||||
|
|
||||||
**Open defect found while doing this: iris sometimes never adopts
|
|
||||||
the window's real size.** Measured on the headless rig, ~3 starts in
|
|
||||||
15: the `tabs` example settles showing its 800×600 startup layout in
|
|
||||||
the top-left of a 1920×1200 surface, black around it, and stays that
|
|
||||||
way indefinitely — it is not a screenshot taken too early, since the
|
|
||||||
picture is byte-identical for the next four seconds. What is *not*
|
|
||||||
the cause, each checked: the winit event order is identical in good
|
|
||||||
and bad runs (`Resized(800×600)`, two redraws, `Resized(1920×1200)`,
|
|
||||||
one redraw), the swapchain reports `1920×1200` and
|
|
||||||
`suboptimal=false` on that last draw, and `output_size` is
|
|
||||||
`(1920, 1200)` going into it. It is timing-sensitive in the way that
|
|
||||||
makes it expensive: adding a single `eprintln!` anywhere in the draw
|
|
||||||
or event path hides it completely (0 in 16), which is why the
|
|
||||||
instrumentation above could not catch it in the act. A pointer move
|
|
||||||
does not repair it, because iris only redraws when something
|
|
||||||
changed; an output mode change does, because that is another resize.
|
|
||||||
Left open rather than guessed at. It matters most for **I2**, where
|
|
||||||
every rotation and every keyboard open is a resize, so a stale frame
|
|
||||||
would be the normal case rather than a rare one; a Wayland-level
|
|
||||||
trace of the xdg-surface configure/ack/commit sequence is the next
|
|
||||||
step, not more `eprintln`.
|
|
||||||
|
|
||||||
One thing was fixed on the way, and it is not that bug: `update`
|
|
||||||
redrew everything when `resized` was set, but `needs_redraw` — which
|
|
||||||
is what decides whether to *ask* for a frame — did not know about
|
|
||||||
`resized` at all. The two now share one `needs_redraw_all`, since a
|
|
||||||
condition in one and not the other is a frame nobody requests. It is
|
|
||||||
latent on Wayland only because winit asks for a redraw after a resize
|
|
||||||
by itself; on Android, where the surface work of I2 will not have
|
|
||||||
winit underneath it, nothing else here would have asked.
|
|
||||||
- [ ] **I1 — the text stack decision.** iris uses cosmic-text; the
|
|
||||||
transcript needs rich inline spans (links, code chips, colour),
|
|
||||||
selection across many widgets with the platform's handles on the
|
|
||||||
phone, and an editor the IME can drive (composition regions, not
|
|
||||||
just committed characters). Compare cosmic-text and Parley against
|
|
||||||
exactly those three, in iris, on a real transcript's text. Parley
|
|
||||||
is the expectation because android-view's IME bridge and AccessKit's
|
|
||||||
text properties are written against it; measure rather than assume.
|
|
||||||
Pass: a written decision here with what each was tried on, and the
|
|
||||||
TODO's "text resizing per frame is really slow" measured and either
|
|
||||||
fixed or explained.
|
|
||||||
- [ ] **I2 — iris on android-view.** An `android-view` surface as a second
|
|
||||||
backend beside winit: `wgpu` on the view's surface (GLES here, see
|
|
||||||
the Vulkan section; Vulkan on the phone), touch as pointer events,
|
|
||||||
window insets and the keyboard inset as layout inputs, the back
|
|
||||||
gesture as an event, the IME bridge feeding the editor from I1.
|
|
||||||
Pass: the `tabs` example and a text field run on the emulator, and
|
|
||||||
the phone's own keyboard types into the field with autocorrect and
|
|
||||||
suggestions — the same bar as E1.
|
|
||||||
- [ ] **I3 — a virtualised, bottom-anchored list.** Variable-height rows,
|
|
||||||
keyed, composed only while visible, paged in both directions with a
|
|
||||||
"more" sentinel at each end, a scroll anchor that survives rows
|
|
||||||
being inserted above, and "hold the edge nearest the tap" done in
|
|
||||||
the layout pass. Pass: 800 rows of real transcript text from the
|
|
||||||
sandbox scroll without a frame over the Compose baseline in
|
|
||||||
`transcript-bench.sh`, measured on the GPU emulator.
|
|
||||||
- [ ] **I4 — accessibility names via AccessKit.** Every control carries a
|
|
||||||
name; `ui-trace` can find and tap it by label. Pass: `bench-lib.sh`'s
|
|
||||||
tap-by-name works against the iris screen unchanged.
|
|
||||||
- [ ] **I5 — the transcript screen in iris.** E2's pass conditions, all
|
|
||||||
seven behaviours, against the sandbox with `--delay`. This is the
|
|
||||||
point the decision in the recommendation is made at.
|
|
||||||
|
|
||||||
## For the next agent
|
|
||||||
|
|
||||||
What to do when you pick this up, in order, so nothing here has to be
|
|
||||||
re-derived:
|
|
||||||
|
|
||||||
1. Read this file, then `AGENTS.md` and `PLAN.md`. The rules there
|
|
||||||
(measure, do not read; fix the rig before accepting its limits; the
|
|
||||||
emulator is this checkout's own) all apply.
|
|
||||||
2. Work on the **`rustify`** branch of this clone (`ai-app-2`), not on
|
|
||||||
`main` and not in `ai-app`. Nothing on this branch is production until
|
|
||||||
Iris says so. Commit and push as you go.
|
|
||||||
3. Take the next unchecked box above, in order: E0 first, then E1, then
|
|
||||||
the iris track from I0. E-steps and I-steps can proceed in parallel in
|
|
||||||
separate sessions once E1 has proved android-view on this emulator.
|
|
||||||
4. Every step ends with its measurement written into this file beside the
|
|
||||||
box, and the box ticked or the reason it could not be written in its
|
|
||||||
place. A step that is blocked says by what, not "later".
|
|
||||||
5. Run the existing rigs rather than inventing new ones: `ui-sandbox.sh`
|
|
||||||
for a server with fixtures, `transcript-bench.sh` for the scroll
|
|
||||||
baseline, `ui-trace` for anything positional, `emu up` for the
|
|
||||||
emulator. The Vulkan section below says how to get a Vulkan path in the
|
|
||||||
emulator when a `wgpu` backend needs one.
|
|
||||||
6. Decisions belong here with a date and what was rejected, the way
|
|
||||||
`PLAN.md` does it. Do not put design into commit messages alone.
|
|
||||||
|
|
||||||
### Vulkan in the emulator (measured 2026-09-04)
|
|
||||||
|
|
||||||
A `wgpu` app in this emulator was going to get GLES only, because host
|
|
||||||
Vulkan is switched off in `emulator-tools`. Retried today on Mesa
|
|
||||||
26.1.7: **Venus still fails the same way** — gfxstream picks
|
|
||||||
`externalMemoryMode: OpaqueFd`, probes `VK_FORMAT_R8G8B8A8_UNORM` for an
|
|
||||||
exportable colour buffer, and Venus says the format is unsupported
|
|
||||||
(`Failed to find memory type for ColorBuffers`, fatal before adb sees the
|
|
||||||
device). Venus does advertise `VK_KHR_external_memory_fd` and
|
|
||||||
`VK_EXT_external_memory_dma_buf`, so the gap is specifically opaque-fd
|
|
||||||
image export. gfxstream has a string-valued `VulkanExternalMemoryMode`
|
|
||||||
setting ("overrides what would otherwise be determined automatically"),
|
|
||||||
but `-feature Name=Value` is rejected as a bad feature name, and the only
|
|
||||||
mode words compiled into this emulator's `libgfxstream_backend.so`
|
|
||||||
(37.1.11) are `OpaqueFd`, `Metal` and `none` — there is no dma-buf mode
|
|
||||||
in this build to switch to. So Venus is blocked by the emulator, not by
|
|
||||||
Mesa; retry when the emulator package updates, since upstream gfxstream
|
|
||||||
does have dma-buf external memory.
|
|
||||||
|
|
||||||
What **does** work: pointing the emulator's Vulkan loader at the software
|
|
||||||
ICDs the emulator ships itself, with the feature enabled:
|
|
||||||
|
|
||||||
VK_DRIVER_FILES=$HOME/Android/Sdk/emulator/lib64/vulkan/vk_swiftshader_icd.json \
|
|
||||||
GPU_HOST_FEATURES="-feature Vulkan" emu up
|
|
||||||
|
|
||||||
The guest then reports Vulkan 1.3 (`cmd gpu vkjson`, SwiftShader
|
|
||||||
Subzero) while GLES still runs on the real GPU through virgl — so a
|
|
||||||
Vello/wgpu app can take its real Vulkan path here, with compute shaders,
|
|
||||||
CPU-rasterised. That is enough to test *correctness* of the Vulkan path
|
|
||||||
in the emulator; GPU *performance* of it is a phone measurement either
|
|
||||||
way, exactly as `MACHINE.md` already says about frame times. **lavapipe**
|
|
||||||
(`lvp_icd.json`, the other ICD the emulator ships) selected llvmpipe and
|
|
||||||
booted, then the emulator died right after loading the `default_boot`
|
|
||||||
snapshot with nothing in the log; a snapshot saved under a different
|
|
||||||
Vulkan device is the suspect, and `-no-snapshot-load` is the untested
|
|
||||||
next step. SwiftShader is the one that works today.
|
|
||||||
Making this an `emu` option belongs in `emulator-tools` and is a shared
|
|
||||||
tooling change, so it goes through the other sessions first.
|
|
||||||
|
|
||||||
## Things a Rust app changes elsewhere
|
|
||||||
|
|
||||||
- **`wg-app-link`'s `:link`** (pinned TLS, enrollment store, QR activity)
|
|
||||||
is Kotlin shared with Dev Updater. The certificate code already exists on
|
|
||||||
the Rust side of the submodule; the pinned-CA build step
|
|
||||||
(`generatePinnedCert`) becomes a `build.rs` reading the same path. The QR
|
|
||||||
scanner stays a Kotlin activity, since the camera is a platform feature.
|
|
||||||
- **Tooling** becomes `cargo` for everything but packaging: `cargo test`,
|
|
||||||
`clippy`, `fmt` cover the whole client, which is the motivation. Gradle
|
|
||||||
remains for the APK, signing (`~/.config/ai-app/release.jks`) and Dev
|
|
||||||
Updater's build modes; `build-apk.sh` would call `cargo ndk` first.
|
|
||||||
- **The bench scripts** (`ui-trace` by accessibility label) keep working
|
|
||||||
only if the framework exposes names through AccessKit on Android; that is
|
|
||||||
part of E2's pass condition, not a nicety.
|
|
||||||
- **Icons** stay Nerd Font glyphs from the committed subset; Parley/Fontique
|
|
||||||
loads a font file directly, so `build-icon-font.sh` is unchanged.
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
- iced: [repo](https://github.com/iced-rs/iced), [0.14 release](https://github.com/iced-rs/iced/releases/tag/0.14.0), [Android thread](https://news.ycombinator.com/item?id=46350641), [markdown selection request](https://discourse.iced.rs/t/markdown-widgets-text-should-be-selectable/1107)
|
|
||||||
- Linebender: [2026 Q1 report](https://linebender.org/blog/tmil-25/), [xilem](https://github.com/linebender/xilem), [parley](https://github.com/linebender/parley), [vello](https://github.com/linebender/vello), [vello_hybrid](https://docs.rs/vello_hybrid/latest/vello_hybrid/)
|
|
||||||
- android-view: [repo](https://github.com/rust-mobile/android-view); android-activity [PR #214](https://github.com/rust-mobile/android-activity/pull/214)
|
|
||||||
- winit Android IME: [#1823](https://github.com/rust-windowing/winit/issues/1823), [#2766](https://github.com/rust-windowing/winit/issues/2766), [#2305](https://github.com/rust-windowing/winit/issues/2305)
|
|
||||||
- egui on Android: [discussion #2053](https://github.com/emilk/egui/discussions/2053)
|
|
||||||
- Slint: [Android guide](https://docs.slint.dev/latest/docs/slint/guide/platforms/mobile/android/), [1.15 release](https://slint.dev/blog/slint-1.15-released), [licensing](https://slint.dev/faqs), rich text [#1325](https://github.com/slint-ui/slint/issues/1325), markdown [#6684](https://github.com/slint-ui/slint/issues/6684)
|
|
||||||
- Makepad: [repo](https://github.com/makepad/makepad), [makepad-widgets](https://docs.rs/makepad-widgets), [Robrix](https://github.com/project-robius/robrix), [Robrix releases](https://github.com/project-robius/robrix/releases)
|
|
||||||
- AccessKit: [releases](https://github.com/AccessKit/accesskit/releases)
|
|
||||||
- Build tools: [cargo-ndk](https://github.com/bbqsrc/cargo-ndk), [cargo-apk](https://github.com/rust-mobile/cargo-apk), [rust-mobile](https://github.com/rust-mobile)
|
|
||||||
- uniffi: [repo](https://github.com/mozilla/uniffi-rs), [KMP bindings fork](https://github.com/UbiqueInnovation/uniffi-kotlin-multiplatform-bindings)
|
|
||||||
- GPUI mobile: [gpui-mobile](https://github.com/itsbalamurali/gpui-mobile)
|
|
||||||
- The earlier Dioxus spike's findings on `wgpu`/Vulkan in this emulator: `~/repos/tdep-survey/app-dioxus/README.md`
|
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
# Subagents
|
||||||
|
|
||||||
|
A session's subagents -- the helpers a Claude Code session starts through its
|
||||||
|
Task tool -- each get a transcript of their own, listed under the session's
|
||||||
|
card and readable in the same transcript view the session has. Designed
|
||||||
|
2026-09-05; the decisions Bryan has not yet reviewed are in `DECISIONS.md`.
|
||||||
|
|
||||||
|
## What a subagent is here
|
||||||
|
|
||||||
|
**A subagent is a second transcript owned by a session, in the same event
|
||||||
|
model, with no process and no controls.** It is not a session: it cannot be
|
||||||
|
messaged, stopped or started, and it has no setup, model or usage of its
|
||||||
|
own. Everything it shares with a session -- the transcript file format, the
|
||||||
|
paging routes, the SSE stream, the phone's cache and rendering -- is reused
|
||||||
|
by addressing, not by copying.
|
||||||
|
|
||||||
|
The CLI reports a subagent's messages on the parent's own stream-json
|
||||||
|
output, each carrying `parent_tool_use_id` = the id of the Task `tool_use`
|
||||||
|
that started it. Before this the translator dropped those lines
|
||||||
|
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
|
||||||
|
them to that subagent's own translator and transcript. The parent's
|
||||||
|
transcript still shows only the Task call itself.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Under the session directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
<session>/subagents/<tool_use_id>/meta.json {title, created}
|
||||||
|
<session>/subagents/<tool_use_id>/transcript.jsonl same SeqEvent lines as the session's
|
||||||
|
```
|
||||||
|
|
||||||
|
The id is the Task tool_use id (`toolu_…`), which is unique, stable across a
|
||||||
|
backend restart, and already the key everything on the parent side uses.
|
||||||
|
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
|
||||||
|
id becomes a path.
|
||||||
|
|
||||||
|
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
|
||||||
|
`read_window`, `catch_up` and `read_after` work on it unchanged.
|
||||||
|
|
||||||
|
Its path out: deleting the session deletes its directory, subagents included.
|
||||||
|
There is no separate delete.
|
||||||
|
|
||||||
|
## Lifecycle, as events in the subagent's transcript
|
||||||
|
|
||||||
|
1. Created on the first child line for an unseen parent id (or, when the
|
||||||
|
parent Task call was seen, at that call). First lines written:
|
||||||
|
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
|
||||||
|
the prompt is known -- it genuinely is the subagent's first user turn.
|
||||||
|
2. Every child line is translated by that subagent's own `Translator`
|
||||||
|
(one per subagent: tool ids are unique but streaming deltas are by
|
||||||
|
content-block index, and parallel subagents interleave).
|
||||||
|
3. **The parent's `tool_result` never finishes a subagent.** The Task tool
|
||||||
|
runs in the background by default: the `tool_result` -- "Async agent
|
||||||
|
launched..." -- arrives the moment it *starts*, while the subagent goes
|
||||||
|
on working for however long its own turn takes, sometimes minutes. What
|
||||||
|
ends it is its own turn ending: the raw API's `message_delta` on its
|
||||||
|
stream carrying `stop_reason: "end_turn"` (a `stop_reason` of `tool_use`
|
||||||
|
is the model about to call one, not an end), or a `result` line for its
|
||||||
|
own turn if a future CLI version ever sends one. Either maps to
|
||||||
|
`Status Exited`; the subagent's vocabulary has no `Idle`, so the
|
||||||
|
equivalent event `dispatch` produces for an ordinary session is dropped
|
||||||
|
rather than written. A shipped version of this finished on the
|
||||||
|
`tool_result` instead, which read a running background agent as
|
||||||
|
"finished" with its transcript truncated at the moment it launched.
|
||||||
|
4. **A child line for a subagent that already finished reopens it**
|
||||||
|
(`Status Running`) rather than being dropped: a background Task can be
|
||||||
|
sent another message long after its first turn ended, and that is
|
||||||
|
exactly what a further line for it means. Same transcript, same child
|
||||||
|
`Translator`, just picking back up.
|
||||||
|
5. When the parent session's process exits (`Status Exited` on the
|
||||||
|
session), every subagent still `Running` gets `Status Exited` too: its
|
||||||
|
process was the parent's.
|
||||||
|
|
||||||
|
A subagent that was mid-flight when the backend restarted keeps working:
|
||||||
|
the registry reopens the existing transcript on the next child line, and
|
||||||
|
the file continues its sequence -- the same reopening #4 describes, whether
|
||||||
|
what closed it was a restart or its own `end_turn`. If its turn ended while
|
||||||
|
the backend was down nothing recorded that until the next line arrives, so
|
||||||
|
its last status stays `Running`, which the list reports as **unknown**
|
||||||
|
rather than as running (see the wire shape) until then.
|
||||||
|
|
||||||
|
Title: the Task call's `description` input, then ` (<subagent_type>)` when
|
||||||
|
one is given; falling back to the tool's name when the child arrives before
|
||||||
|
(or without) the parent call being seen.
|
||||||
|
|
||||||
|
## Server layout
|
||||||
|
|
||||||
|
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
|
||||||
|
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
|
||||||
|
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
|
||||||
|
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk. Drivers get an
|
||||||
|
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
|
||||||
|
- `session/claude/translate.rs` -- routes child lines by parent id, holds
|
||||||
|
one child `Translator` per subagent, remembers pending Task calls'
|
||||||
|
description/prompt/subagent_type.
|
||||||
|
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
|
||||||
|
subagents at once, each named "helper k". Each writes the prompt as its
|
||||||
|
user message, streams a few words of text, runs one `Bash` tool call, then
|
||||||
|
finishes about three seconds after starting, and the parent's Task calls
|
||||||
|
end when their subagent does. Three seconds so the running state can be
|
||||||
|
seen on the phone.
|
||||||
|
- `routes.rs` -- three routes, in the doc table.
|
||||||
|
|
||||||
|
## Wire shape
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /sessions/{id} SessionInfo gains `subagents: N` (count, 0 when none)
|
||||||
|
GET /sessions same field on each row
|
||||||
|
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
|
||||||
|
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
|
||||||
|
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` is the transcript's last `Status` event, serialised like a session's
|
||||||
|
(`running`, `exited`), except that a subagent whose session is not itself
|
||||||
|
running cannot be running: the list answers `unknown` for that one. The
|
||||||
|
phone words these as *running*, *finished* and *unknown* on the subcard.
|
||||||
|
|
||||||
|
The count on `SessionInfo` is a directory listing, so the list stays cheap.
|
||||||
|
The per-subagent status is only read when the list route is asked for.
|
||||||
|
|
||||||
|
## Phone
|
||||||
|
|
||||||
|
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
|
||||||
|
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
|
||||||
|
`Pointing.Up` -- collapsed by default. Expanding fetches
|
||||||
|
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent,
|
||||||
|
indented inside the session card, the way dev-updater draws a project's
|
||||||
|
components: title, then the status word and a relative time. The
|
||||||
|
expansion state is per session id and survives a refresh of the list.
|
||||||
|
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in
|
||||||
|
**read-only** form: the same transcript, paging, cache, selection,
|
||||||
|
images and status row, with the composer, the process button, the model
|
||||||
|
picker, the files button, the settings cog and the usage bar left out.
|
||||||
|
The header shows the subagent's title with the session's title beneath
|
||||||
|
it. Back returns to the list.
|
||||||
|
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
|
||||||
|
cache take a transcript address rather than a session id --
|
||||||
|
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
|
||||||
|
subagent's copy under its session's and the same code serves both.
|
||||||
Generated
+1081
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
name = "android-shell"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
|
||||||
|
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
|
||||||
|
# why those two classes cannot be anything but Java/Kotlin, registered from
|
||||||
|
# the manifest by name). Everything they would otherwise have done in
|
||||||
|
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
|
||||||
|
# picking a session for a share -- is here instead, built on `client-core`
|
||||||
|
# so the networking and parsing are not duplicated a third time next to the
|
||||||
|
# server and the Kotlin app.
|
||||||
|
#
|
||||||
|
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
|
||||||
|
# on a normal host target without an Android NDK toolchain, the same
|
||||||
|
# posture `client-core` and `server` already have.
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "android_shell"
|
||||||
|
crate-type = ["cdylib", "lib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
client-core = { path = "../client-core" }
|
||||||
|
jni = "0.22"
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
|
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
|
||||||
|
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
|
||||||
|
# without a backend installed -- so without this, every recoverable error
|
||||||
|
# at a native entry point would be silently dropped rather than reaching
|
||||||
|
# logcat. Android-only: nothing else here needs it, and it does not build
|
||||||
|
# off-device (see `notify::ensure_logger`'s call site, the only place this
|
||||||
|
# is used).
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
android_logger = "0.15"
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! Thin wrappers around the five `Env` calls this crate makes constantly
|
||||||
|
//! (a class name, a method name and a signature, all as plain `&str`).
|
||||||
|
//!
|
||||||
|
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
|
||||||
|
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
|
||||||
|
//! here uniformly rather than switching to the compile-time `jni_str!`
|
||||||
|
//! literal macro call by call -- these are a handful of short, one-off
|
||||||
|
//! lookups, not a hot loop, so the difference is not worth two code paths
|
||||||
|
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
|
||||||
|
//! `FieldSignature`, which is why those go through
|
||||||
|
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
|
||||||
|
//! parsed form is what lets these calls skip re-validating the signature
|
||||||
|
//! against the arguments on every call, which is the whole reason `jni`
|
||||||
|
//! moved to it.
|
||||||
|
//!
|
||||||
|
//! **The classloader gotcha, found by testing (2026-09-05).** A class
|
||||||
|
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
|
||||||
|
//! `get_static_field` -- anything that resolves a *class*, as opposed to
|
||||||
|
//! `call_method` on an object it already has, which needs no such lookup)
|
||||||
|
//! defaults to `FindClass`'s ordinary search when it cannot find the
|
||||||
|
//! calling thread a classloader through `Thread.getContextClassLoader()`.
|
||||||
|
//! That default is fine on a thread the JVM itself started -- an
|
||||||
|
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
|
||||||
|
//! from `android-shell`'s own background thread (the notification
|
||||||
|
//! follow-loop, the share upload) is running on a thread *Rust* spawned
|
||||||
|
//! and attached with `JavaVM::attach_current_thread`, which the platform
|
||||||
|
//! never gave an app classloader. Framework classes
|
||||||
|
//! (`android.app.Notification$Builder`, ...) still resolve, because they
|
||||||
|
//! are reachable from the bootstrap loader `FindClass` falls back to --
|
||||||
|
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
|
||||||
|
//! packaged inside this app's own APK. The failure was
|
||||||
|
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
|
||||||
|
//! as "failed to resolve Java class ... (class not found or linkage
|
||||||
|
//! error)" -- on a real device this reads as "the notification silently
|
||||||
|
//! never arrives," since the whole call is inside the follow loop and the
|
||||||
|
//! ongoing foreground notification (built on the main thread, in
|
||||||
|
//! `try_start`, before the background thread exists) posts fine either
|
||||||
|
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
|
||||||
|
//! first time any entry point has a `Context` to ask, and every class
|
||||||
|
//! lookup below goes through it explicitly via `LoaderContext::Loader`
|
||||||
|
//! rather than the thread-dependent default -- so it is correct on the
|
||||||
|
//! main thread and on this crate's own background threads alike.
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
/// Caches `context`'s own `ClassLoader`, the first time this is called.
|
||||||
|
/// Cheap to call from every entry point that has a `Context` on hand
|
||||||
|
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
|
||||||
|
/// a `OnceLock::get` and nothing else.
|
||||||
|
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
||||||
|
if CLASS_LOADER.get().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// context.getClass().getClassLoader() -- resolved via `call_method` on
|
||||||
|
// real objects throughout, so this needs no class-name lookup of its
|
||||||
|
// own and has nothing to bootstrap.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new Java string as a plain `JObject` -- what every call site here
|
||||||
|
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
|
||||||
|
/// `JString: Into<JObject>` is the documented way across).
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
|
||||||
|
//! package comment for what this crate is and RUST.md's E3 entry for the
|
||||||
|
//! design decisions.
|
||||||
|
//!
|
||||||
|
//! Each native method is declared with `jni`'s [`native_method!`] macro
|
||||||
|
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
|
||||||
|
//! the macro derives the mangled export name and the JNI signature from the
|
||||||
|
//! Rust function itself, so the two cannot drift apart the way a
|
||||||
|
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
|
||||||
|
//! routinely do. `error_policy = LogErrorAndDefault` matches
|
||||||
|
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
|
||||||
|
//! JNI call that threw) is reported to logcat, not thrown back into Java
|
||||||
|
//! as an exception that would crash the app over something recoverable.
|
||||||
|
//!
|
||||||
|
//! Each `const _: NativeMethod = native_method! { ... };` binding is
|
||||||
|
//! otherwise unused by name -- `_` is the idiomatic way to keep a
|
||||||
|
//! side-effecting const (here, generating the `#[export_name]`d function
|
||||||
|
//! the JVM resolves by the JNI naming convention) without a `dead_code`
|
||||||
|
//! warning for a binding nothing reads.
|
||||||
|
|
||||||
|
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};
|
||||||
|
|
||||||
|
/// Installs the `log` backend that routes to logcat, once per process.
|
||||||
|
/// Without it, `LogErrorAndDefault` (every native method below) and any
|
||||||
|
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
|
||||||
|
/// call into the `log` facade's default no-op logger, and a real failure
|
||||||
|
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
|
||||||
|
/// crashing, since nothing on screen or in the log says a notification was
|
||||||
|
/// dropped. Called from every entry point below rather than a Java-side
|
||||||
|
/// `Application.onCreate`, since this crate deliberately has no such class
|
||||||
|
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
|
||||||
|
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
|
||||||
|
/// enrollment may have just landed) and from `NotificationService.sync`
|
||||||
|
/// itself. See `notify::sync`.
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
|
||||||
|
fn native_on_destroy<'local>(
|
||||||
|
_env: &mut Env<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
) -> Result<(), jni::errors::Error> {
|
||||||
|
ensure_logger();
|
||||||
|
notify::on_destroy();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
//! 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 client_core::api::UreqTransport;
|
||||||
|
use client_core::notifications::{SessionNotification, follow_notifications};
|
||||||
|
use jni::Env;
|
||||||
|
use jni::errors::Result;
|
||||||
|
use jni::objects::{JObject, JValue};
|
||||||
|
use jni::sys::{JNI_TRUE, jint};
|
||||||
|
|
||||||
|
use crate::settings::{self, ServerSettings};
|
||||||
|
|
||||||
|
const ALERT_CHANNEL: &str = "sessions";
|
||||||
|
const ONGOING_CHANNEL: &str = "connection";
|
||||||
|
const ONGOING_ID: i32 = 1;
|
||||||
|
const ALERT_ID: i32 = 2;
|
||||||
|
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// Set by `nativeOnDestroy`, checked by the follow loop between
|
||||||
|
/// reconnects. **Known gap, recorded rather than hidden**: unlike
|
||||||
|
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
|
||||||
|
/// can interrupt a `ureq` read already blocked inside one connection --
|
||||||
|
/// `Transport::stream` hands back a plain `Read` with no cancellation
|
||||||
|
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
|
||||||
|
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
|
||||||
|
/// practice this is a bounded wait rather than a hang; closing that gap
|
||||||
|
/// for real means adding a cancellation point to `client_core::Transport`,
|
||||||
|
/// which is a decision affecting every caller of that trait, not just this
|
||||||
|
/// one -- left for whoever next depends on prompt shutdown.
|
||||||
|
static STOPPING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
||||||
|
crate::jcall::get_static_field(env, class, field, "I")?.i()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||||
|
crate::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::jcall::jstr_obj(env, id)?;
|
||||||
|
let builder = crate::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::jcall::jstr_obj(env, name)?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setName",
|
||||||
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
||||||
|
&[JValue::Object(&name_j)],
|
||||||
|
)?;
|
||||||
|
let channel = crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"build",
|
||||||
|
"()Landroidx/core/app/NotificationChannelCompat;",
|
||||||
|
&[],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
crate::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::jcall::find_class(env, class_name)?;
|
||||||
|
crate::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::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&intent,
|
||||||
|
"setAction",
|
||||||
|
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||||
|
&[JValue::Object(&action_view)],
|
||||||
|
)?;
|
||||||
|
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
||||||
|
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"scheme",
|
||||||
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
|
&[JValue::Object(&scheme)],
|
||||||
|
)?;
|
||||||
|
let authority = crate::jcall::jstr_obj(env, "session")?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"authority",
|
||||||
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
|
&[JValue::Object(&authority)],
|
||||||
|
)?;
|
||||||
|
let path = crate::jcall::jstr_obj(env, session_id)?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"appendPath",
|
||||||
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
|
&[JValue::Object(&path)],
|
||||||
|
)?;
|
||||||
|
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
|
||||||
|
crate::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::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::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::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
||||||
|
let builder = crate::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::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::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::jcall::call_method(
|
||||||
|
env,
|
||||||
|
context,
|
||||||
|
"stopService",
|
||||||
|
"(Landroid/content/Intent;)Z",
|
||||||
|
&[JValue::Object(&service_intent)],
|
||||||
|
)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
create_channels(env, context)?;
|
||||||
|
crate::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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Service.onStartCommand` body -- loads settings, starts the
|
||||||
|
/// foreground notification, and spawns the follow-loop thread. Answers the
|
||||||
|
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
|
||||||
|
/// framework rather than hardcoded so a wrong guess at their values cannot
|
||||||
|
/// silently pick the other behaviour.
|
||||||
|
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::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::jcall::call_static_method(
|
||||||
|
env,
|
||||||
|
"androidx/core/app/ServiceCompat",
|
||||||
|
"startForeground",
|
||||||
|
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
|
||||||
|
&[
|
||||||
|
JValue::Object(service),
|
||||||
|
JValue::Int(ONGOING_ID),
|
||||||
|
JValue::Object(¬ification),
|
||||||
|
JValue::Int(fg_type),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
|
||||||
|
// the first is still going -- the ordinary case for this service,
|
||||||
|
// since `sync()` is called from more than one place -- must not open
|
||||||
|
// a second connection.
|
||||||
|
if RUNNING.swap(true, Ordering::SeqCst) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vm = env.get_java_vm()?;
|
||||||
|
let context = env.new_global_ref(service)?;
|
||||||
|
STOPPING.store(false, Ordering::SeqCst);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("ai-app-notifications".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
// Requests a *permanent* attachment (detached only when this thread
|
||||||
|
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
|
||||||
|
// this is the long-lived follow loop, not a one-shot callback.
|
||||||
|
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||||
|
follow_loop(env, &context, settings, &ca);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows the backend's notification stream, reconnecting until stopped
|
||||||
|
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
|
||||||
|
/// ordinary case, so it retries quietly and forever; nothing is shown when
|
||||||
|
/// it cannot connect, for the same reason as the Kotlin original: a
|
||||||
|
/// notification saying "I could not tell you whether anything happened" is
|
||||||
|
/// noise about a condition nobody can act on.
|
||||||
|
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
||||||
|
while !STOPPING.load(Ordering::SeqCst) {
|
||||||
|
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
||||||
|
let _ = follow_notifications(&transport, |notification| {
|
||||||
|
if let Err(e) = show(env, context, ¬ification) {
|
||||||
|
log_error(env, "show", &e);
|
||||||
|
}
|
||||||
|
!STOPPING.load(Ordering::SeqCst)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if STOPPING.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(RECONNECT_DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One notification per session, replacing that session's previous one --
|
||||||
|
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
|
||||||
|
/// branches this module's doc comment explains.
|
||||||
|
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::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
||||||
|
let granted = static_int(
|
||||||
|
env,
|
||||||
|
"android/content/pm/PackageManager",
|
||||||
|
"PERMISSION_GRANTED",
|
||||||
|
)?;
|
||||||
|
let result = crate::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::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
|
||||||
|
if !allowed || !enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let intent = session_intent(env, context, ¬ification.session_id)?;
|
||||||
|
let pending = pending_activity(env, context, &intent)?;
|
||||||
|
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
||||||
|
let builder = crate::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::jcall::jstr_obj(env, ¬ification.title)?;
|
||||||
|
builder_call(
|
||||||
|
env,
|
||||||
|
&builder,
|
||||||
|
"setContentTitle",
|
||||||
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
|
&[JValue::Object(&title)],
|
||||||
|
)?;
|
||||||
|
let text = crate::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::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let tag = crate::jcall::jstr_obj(env, ¬ification.session_id)?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&manager,
|
||||||
|
"notify",
|
||||||
|
"(Ljava/lang/String;ILandroid/app/Notification;)V",
|
||||||
|
&[
|
||||||
|
JValue::Object(&tag),
|
||||||
|
JValue::Int(ALERT_ID),
|
||||||
|
JValue::Object(&built),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
|
||||||
|
/// the gap this module's `STOPPING` doc explains.
|
||||||
|
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::jcall::jstr_obj(env, "android-shell")?;
|
||||||
|
let msg = crate::jcall::jstr_obj(env, &message)?;
|
||||||
|
crate::jcall::call_static_method(
|
||||||
|
env,
|
||||||
|
"android/util/Log",
|
||||||
|
"e",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)I",
|
||||||
|
&[JValue::Object(&tag), JValue::Object(&msg)],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This experiment's own scheme and Keystore alias -- distinct from the
|
||||||
|
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
|
||||||
|
/// installed side by side on the same development device without
|
||||||
|
/// colliding over which one a scanned QR or a deep link resolves to. See
|
||||||
|
/// RUST.md's E3 entry for why they are not the same value.
|
||||||
|
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::jcall::jstr_obj(env, SCHEME)?;
|
||||||
|
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
|
||||||
|
crate::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::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::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::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)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
|
||||||
|
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
||||||
|
let store = new_store(env)?;
|
||||||
|
let host = crate::jcall::jstr_obj(env, &settings.host)?;
|
||||||
|
let token = crate::jcall::jstr_obj(env, &settings.token)?;
|
||||||
|
let settings_obj = crate::jcall::new_object(
|
||||||
|
env,
|
||||||
|
SETTINGS_CLASS,
|
||||||
|
"(Ljava/lang/String;ILjava/lang/String;)V",
|
||||||
|
&[
|
||||||
|
JValue::Object(&host),
|
||||||
|
JValue::Int(settings.port),
|
||||||
|
JValue::Object(&token),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
&store,
|
||||||
|
"save",
|
||||||
|
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
|
||||||
|
&[JValue::Object(context), JValue::Object(&settings_obj)],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
|
||||||
|
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
|
||||||
|
/// query-parameter rules rather than re-deriving them here.
|
||||||
|
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
||||||
|
let store = new_store(env)?;
|
||||||
|
let settings_obj = crate::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::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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
|
||||||
|
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
|
||||||
|
//!
|
||||||
|
//! **Scope cut, recorded rather than silent**: only shared *text*
|
||||||
|
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
|
||||||
|
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
|
||||||
|
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
|
||||||
|
//! ported here, because `client-core`'s `ApiClient` does not have the
|
||||||
|
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
|
||||||
|
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
|
||||||
|
//! `content://` stream and no text falls through to a toast saying so,
|
||||||
|
//! rather than silently doing nothing. Closing this gap is the same
|
||||||
|
//! `client-core` work whichever caller needs it next.
|
||||||
|
//!
|
||||||
|
//! **Which session a share lands in** is also a placeholder: with no
|
||||||
|
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
|
||||||
|
//! to whichever session has the latest `last_activity` -- the one most
|
||||||
|
//! likely to be what somebody meant. Worth revisiting once a real screen
|
||||||
|
//! exists to ask instead of guessing.
|
||||||
|
|
||||||
|
use client_core::api::{ApiClient, UreqTransport};
|
||||||
|
use jni::Env;
|
||||||
|
use jni::errors::Result;
|
||||||
|
use jni::objects::{JObject, JString, JValue};
|
||||||
|
|
||||||
|
use crate::notify;
|
||||||
|
use crate::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::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::jcall::jstr_obj(env, message)?;
|
||||||
|
crate::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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one place an incoming intent is sorted into what it means -- mirrors
|
||||||
|
/// `MainActivity.kt`'s `handleIntent`.
|
||||||
|
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::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(());
|
||||||
|
};
|
||||||
|
// There is no session screen yet (E4's job); the toast is this
|
||||||
|
// experiment's stand-in proof that the tap was routed to the right
|
||||||
|
// session id.
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
|
||||||
|
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
|
||||||
|
/// what happens to it, minus attachments per this module's doc comment.
|
||||||
|
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||||
|
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||||
|
let text = crate::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)
|
||||||
|
}
|
||||||
@@ -102,6 +102,16 @@ android {
|
|||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
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 {
|
packaging {
|
||||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||||
@@ -131,6 +141,35 @@ android {
|
|||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
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 {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
the fix is a judgement about how this app should look. Drop this
|
the fix is a judgement about how this app should look. Drop this
|
||||||
suppression when a real icon lands. -->
|
suppression when a real icon lands. -->
|
||||||
<application
|
<application
|
||||||
android:label="AI Sessions"
|
android:label="@string/app_name"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||||
tools:ignore="MissingApplicationIcon">
|
tools:ignore="MissingApplicationIcon">
|
||||||
|
|||||||
@@ -142,6 +142,20 @@ data class SessionSummary(
|
|||||||
val keepsOwnTranscript: Boolean,
|
val keepsOwnTranscript: Boolean,
|
||||||
/** How much the session asks before acting; null when it was never set. */
|
/** How much the session asks before acting; null when it was never set. */
|
||||||
val permissionMode: String?,
|
val permissionMode: String?,
|
||||||
|
/**
|
||||||
|
* How hard the model thinks, or null for the CLI's own default.
|
||||||
|
*
|
||||||
|
* Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is
|
||||||
|
* reported rather than assumed for the same reason [permissionMode] is.
|
||||||
|
*/
|
||||||
|
val effort: String?,
|
||||||
|
/**
|
||||||
|
* Whether a thinking level does anything here -- a Claude CLI session, not a llama or echo one.
|
||||||
|
*
|
||||||
|
* Asked of the server rather than worked out from the provider's name, because this is a
|
||||||
|
* property of the driver's *kind* and the phone only has the name.
|
||||||
|
*/
|
||||||
|
val takesEffort: Boolean,
|
||||||
/**
|
/**
|
||||||
* Whether this continues a session the machine already had, which changes what deleting means.
|
* Whether this continues a session the machine already had, which changes what deleting means.
|
||||||
*/
|
*/
|
||||||
@@ -153,6 +167,24 @@ data class SessionSummary(
|
|||||||
* itself from a default is one you can turn off while believing you are reading it.
|
* itself from a default is one you can turn off while believing you are reading it.
|
||||||
*/
|
*/
|
||||||
val notify: Boolean,
|
val notify: Boolean,
|
||||||
|
/**
|
||||||
|
* Whether this session sends itself a message once its account's usage limit lifts, and what
|
||||||
|
* that message says.
|
||||||
|
*
|
||||||
|
* The message is what the server would actually send, with its own default already filled in,
|
||||||
|
* so the field shows the words rather than an empty box standing for them.
|
||||||
|
*/
|
||||||
|
val autoResume: Boolean,
|
||||||
|
val autoResumeMessage: String,
|
||||||
|
/**
|
||||||
|
* When the server next intends to check whether the limit has lifted, in epoch seconds, or null
|
||||||
|
* when nothing is waiting.
|
||||||
|
*
|
||||||
|
* A time to *ask*, not a time to resume: the server checks the meter at that moment and waits
|
||||||
|
* again if the limit is still on. Worded that way wherever it is shown, because a promise this
|
||||||
|
* app cannot keep is worse than no time at all.
|
||||||
|
*/
|
||||||
|
val resumeAt: Double?,
|
||||||
/**
|
/**
|
||||||
* The directory the session works in, or null where it was never given one.
|
* The directory the session works in, or null where it was never given one.
|
||||||
*
|
*
|
||||||
@@ -178,8 +210,27 @@ data class SessionSummary(
|
|||||||
* server because that is where a provider's kind is known.
|
* server because that is where a provider's kind is known.
|
||||||
*/
|
*/
|
||||||
val maxImageEdge: Int?,
|
val maxImageEdge: Int?,
|
||||||
|
/**
|
||||||
|
* Which of `GET /usage`'s snapshots is about this session, and null where nothing meters it.
|
||||||
|
*
|
||||||
|
* The rate-limit bar answers a question about an *account*, and what decides which account --
|
||||||
|
* if any -- is the provider this session runs, not the machine it runs on. Pairing by machine
|
||||||
|
* alone drew the Claude CLI's five-hour window under every echo session on a machine that also
|
||||||
|
* has the CLI: a quota that session cannot spend and could never run down. Decided by the
|
||||||
|
* server for the same reason [maxImageEdge] is -- it is a fact about the provider's kind, and
|
||||||
|
* this app has only its name.
|
||||||
|
*/
|
||||||
|
val usageProvider: String?,
|
||||||
val status: String,
|
val status: String,
|
||||||
val lastActivity: Double,
|
val lastActivity: Double,
|
||||||
|
/**
|
||||||
|
* How many subagents this session has, however their own status now reads.
|
||||||
|
*
|
||||||
|
* A directory listing on the server rather than a status read per subagent, so the list stays
|
||||||
|
* cheap; the per-subagent state is only fetched when the card is expanded. Zero on a server
|
||||||
|
* that predates subagents, so this app still opens against one.
|
||||||
|
*/
|
||||||
|
val subagents: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun parseSession(session: JSONObject) =
|
private fun parseSession(session: JSONObject) =
|
||||||
@@ -192,14 +243,24 @@ private fun parseSession(session: JSONObject) =
|
|||||||
title = session.getString("title"),
|
title = session.getString("title"),
|
||||||
model = session.optString("model").ifEmpty { null },
|
model = session.optString("model").ifEmpty { null },
|
||||||
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
||||||
|
effort = session.optString("effort").ifEmpty { null },
|
||||||
|
takesEffort = session.optBoolean("takesEffort", false),
|
||||||
imported = session.optBoolean("imported", false),
|
imported = session.optBoolean("imported", false),
|
||||||
notify = session.optBoolean("notify", true),
|
notify = session.optBoolean("notify", true),
|
||||||
|
autoResume = session.optBoolean("autoResume", false),
|
||||||
|
// The server sends its own default rather than nothing, so an empty answer means an older
|
||||||
|
// server -- and this app's word for it is the same word.
|
||||||
|
autoResumeMessage =
|
||||||
|
session.optString("autoResumeMessage").ifEmpty { DEFAULT_RESUME_MESSAGE },
|
||||||
|
resumeAt = if (session.has("resumeAt")) session.getDouble("resumeAt") else null,
|
||||||
cwd = session.optString("cwd").ifEmpty { null },
|
cwd = session.optString("cwd").ifEmpty { null },
|
||||||
contextTokens =
|
contextTokens =
|
||||||
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
||||||
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
||||||
|
usageProvider = session.optString("usageProvider").ifEmpty { null },
|
||||||
status = session.getString("status"),
|
status = session.getString("status"),
|
||||||
lastActivity = session.getDouble("lastActivity"),
|
lastActivity = session.getDouble("lastActivity"),
|
||||||
|
subagents = session.optInt("subagents", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||||
@@ -215,6 +276,35 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
|||||||
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
||||||
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
|
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /sessions/{id}/subagents`, oldest first.
|
||||||
|
*
|
||||||
|
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
|
||||||
|
* this carries only what a card needs to draw and to open it; see SUBAGENTS.md. [status] is
|
||||||
|
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
|
||||||
|
* running, and the list says so rather than reporting a state that cannot hold.
|
||||||
|
*/
|
||||||
|
data class SubagentSummary(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val status: String,
|
||||||
|
val created: Double,
|
||||||
|
val lastActivity: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSummary> =
|
||||||
|
requestFromServer(settings, "/sessions/$sessionId/subagents") {
|
||||||
|
it.jsonObjects { row ->
|
||||||
|
SubagentSummary(
|
||||||
|
id = row.getString("id"),
|
||||||
|
title = row.getString("title"),
|
||||||
|
status = row.getString("status"),
|
||||||
|
created = row.getDouble("created"),
|
||||||
|
lastActivity = row.getDouble("lastActivity"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
|
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
|
||||||
// config.ron appears here with no app rebuild.
|
// config.ron appears here with no app rebuild.
|
||||||
//
|
//
|
||||||
@@ -375,6 +465,12 @@ data class SshDetails(
|
|||||||
* Where files attached from here land on that machine; null for the session's own directory.
|
* Where files attached from here land on that machine; null for the session's own directory.
|
||||||
*/
|
*/
|
||||||
val attachmentsDir: String? = null,
|
val attachmentsDir: String? = null,
|
||||||
|
/**
|
||||||
|
* Where that machine keeps its GGUF models; null for the same place the backend keeps its own
|
||||||
|
* (`~/.local/share/ai-app/models`, read on that machine). A llama.cpp session serves the file
|
||||||
|
* from the machine it runs on, so this is where its models are looked for and listed.
|
||||||
|
*/
|
||||||
|
val modelsDir: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SshDetails.toJson() =
|
private fun SshDetails.toJson() =
|
||||||
@@ -382,6 +478,7 @@ private fun SshDetails.toJson() =
|
|||||||
if (port != null) put("port", port)
|
if (port != null) put("port", port)
|
||||||
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
||||||
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
|
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
|
||||||
|
if (!modelsDir.isNullOrBlank()) put("modelsDir", modelsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a machine turns out to have, without saving anything. */
|
/** What a machine turns out to have, without saving anything. */
|
||||||
@@ -450,6 +547,8 @@ fun spawnSession(
|
|||||||
model: String? = null,
|
model: String? = null,
|
||||||
cwd: String? = null,
|
cwd: String? = null,
|
||||||
permissionMode: String? = null,
|
permissionMode: String? = null,
|
||||||
|
/** Null for whatever the server's default is; see [fetchDefaultEffort]. */
|
||||||
|
effort: String? = null,
|
||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
/** Continue this Claude Code session instead of starting an empty one. */
|
/** Continue this Claude Code session instead of starting an empty one. */
|
||||||
import: String? = null,
|
import: String? = null,
|
||||||
@@ -467,6 +566,7 @@ fun spawnSession(
|
|||||||
if (!model.isNullOrBlank()) put("model", model)
|
if (!model.isNullOrBlank()) put("model", model)
|
||||||
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
||||||
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
||||||
|
if (!effort.isNullOrBlank()) put("effort", effort)
|
||||||
if (!import.isNullOrBlank()) put("import", import)
|
if (!import.isNullOrBlank()) put("import", import)
|
||||||
if (params.isNotEmpty()) {
|
if (params.isNotEmpty()) {
|
||||||
put("params", JSONObject(params.toMap<String, Any>()))
|
put("params", JSONObject(params.toMap<String, Any>()))
|
||||||
@@ -906,7 +1006,7 @@ fun startImport(
|
|||||||
*/
|
*/
|
||||||
fun fetchTranscript(
|
fun fetchTranscript(
|
||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
sessionId: String,
|
address: TranscriptAddress,
|
||||||
before: Long? = null,
|
before: Long? = null,
|
||||||
limit: Int = 80,
|
limit: Int = 80,
|
||||||
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
|
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
|
||||||
@@ -925,7 +1025,7 @@ fun fetchTranscript(
|
|||||||
if (coalesce) append("&coalesce=true")
|
if (coalesce) append("&coalesce=true")
|
||||||
if (after != null) append("&after=").append(after)
|
if (after != null) append("&after=").append(after)
|
||||||
}
|
}
|
||||||
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
|
return requestFromServer(settings, "/${address.urlPath}/transcript$query") { connection ->
|
||||||
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
||||||
// The text as well as the event: the transcript cache stores the one and the fold needs the
|
// The text as well as the event: the transcript cache stores the one and the fold needs the
|
||||||
// other, and they have to be the same line.
|
// other, and they have to be the same line.
|
||||||
@@ -973,6 +1073,56 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
|
|||||||
*/
|
*/
|
||||||
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a new session's thinking level is when nothing chose one, or null for the CLI's own.
|
||||||
|
*
|
||||||
|
* Held by the server rather than by this phone, because a second device would otherwise spawn
|
||||||
|
* sessions at a level the first one's owner never picked.
|
||||||
|
*/
|
||||||
|
fun fetchDefaultEffort(settings: ServerSettings): String? =
|
||||||
|
requestFromServer(settings, "/defaults") {
|
||||||
|
it.jsonObject().optString("effort").ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets what new sessions start at. Nothing already running changes. */
|
||||||
|
fun setDefaultEffort(settings: ServerSettings, level: String?) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/defaults",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How hard the model thinks, as `claude --effort` takes them, cheapest first.
|
||||||
|
*
|
||||||
|
* Not offered alongside the model and the permission mode on the session's own bar, because it does
|
||||||
|
* not behave like them: the CLI has a control request for those two and none for this (checked
|
||||||
|
* against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops
|
||||||
|
* the process, which is what the working directory beside it in this dialog does, and why it is
|
||||||
|
* here rather than on a bar whose other controls take effect mid-turn.
|
||||||
|
*/
|
||||||
|
val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max")
|
||||||
|
|
||||||
|
/** What the picker shows, and sends as null, for a session that has chosen no level. */
|
||||||
|
const val DEFAULT_EFFORT = "default"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records how hard a session thinks and **stops its process**, since the level is read when the
|
||||||
|
* process is launched. The next message, or Start, runs one that has it.
|
||||||
|
*
|
||||||
|
* [level] is null for the CLI's own default.
|
||||||
|
*/
|
||||||
|
fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/sessions/$sessionId/effort",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
/** Switches how much a running session asks before acting, also in place. */
|
/** Switches how much a running session asks before acting, also in place. */
|
||||||
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
||||||
requestFromServer(
|
requestFromServer(
|
||||||
@@ -984,6 +1134,36 @@ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
|
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
|
||||||
|
/**
|
||||||
|
* What an auto-resume says when nothing else was typed. Mirrors the server's own default, so a
|
||||||
|
* cleared field shows the word that would actually be sent instead of going blank.
|
||||||
|
*/
|
||||||
|
const val DEFAULT_RESUME_MESSAGE = "continue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns auto-resume on or off and sets what it would say, in one request because they are one
|
||||||
|
* decision -- see the server's `/sessions/{id}/auto-resume`.
|
||||||
|
*/
|
||||||
|
fun setSessionAutoResume(
|
||||||
|
settings: ServerSettings,
|
||||||
|
sessionId: String,
|
||||||
|
autoResume: Boolean,
|
||||||
|
message: String?,
|
||||||
|
) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/sessions/$sessionId/auto-resume",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody =
|
||||||
|
JSONObject()
|
||||||
|
.put("autoResume", autoResume)
|
||||||
|
// Empty means the server's default rather than a session poked with nothing to
|
||||||
|
// read, which is the same rule the server applies to the field.
|
||||||
|
.put("message", message?.trim()?.ifEmpty { null } ?: JSONObject.NULL)
|
||||||
|
.toString(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
|
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
|
||||||
requestFromServer(
|
requestFromServer(
|
||||||
settings,
|
settings,
|
||||||
@@ -1074,6 +1254,26 @@ private fun parseDownload(o: JSONObject) =
|
|||||||
error = if (o.has("error")) o.getString("error") else null,
|
error = if (o.has("error")) o.getString("error") else null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The models on one machine, which is the list a llama.cpp session there can choose from.
|
||||||
|
*
|
||||||
|
* Not [fetchModels], which is what the *backend* has downloaded. A session serves its model from
|
||||||
|
* the machine it runs on, so for a machine reached over ssh those are two different lists -- and
|
||||||
|
* offering the backend's would name files that are not there, turning a choice that cannot work
|
||||||
|
* into a session that fails when it tries to load one.
|
||||||
|
*/
|
||||||
|
fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel> =
|
||||||
|
requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection ->
|
||||||
|
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
|
||||||
|
LocalModel(
|
||||||
|
key = m.getString("key"),
|
||||||
|
repo = m.getString("repo"),
|
||||||
|
file = m.getString("file"),
|
||||||
|
bytes = m.getLong("bytes"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun fetchModels(settings: ServerSettings): Models =
|
fun fetchModels(settings: ServerSettings): Models =
|
||||||
requestFromServer(settings, "/models") { connection ->
|
requestFromServer(settings, "/models") { connection ->
|
||||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -34,7 +36,23 @@ import kotlinx.coroutines.withContext
|
|||||||
* session, spawning one, and settings.
|
* session, spawning one, and settings.
|
||||||
*/
|
*/
|
||||||
private sealed class Screen {
|
private sealed class Screen {
|
||||||
data object Main : 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
|
||||||
|
* 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.
|
* One session, with the file explorer over it when [files] is set.
|
||||||
@@ -81,7 +99,7 @@ fun AppRoot(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
||||||
var screen by remember { mutableStateOf<Screen>(Screen.Main) }
|
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
|
// 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.
|
// after one succeeds, since success is a screen rather than a message.
|
||||||
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
||||||
@@ -96,7 +114,7 @@ fun AppRoot(
|
|||||||
share = shareRequest
|
share = shareRequest
|
||||||
// A session already open takes it. Otherwise the list is where the choice is made,
|
// 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.
|
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
|
||||||
if (screen !is Screen.Session) screen = Screen.Main
|
if (screen !is Screen.Session) screen = Screen.Main()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +141,7 @@ fun AppRoot(
|
|||||||
existing = null,
|
existing = null,
|
||||||
onSaved = { saved ->
|
onSaved = { saved ->
|
||||||
settings = saved
|
settings = saved
|
||||||
screen = Screen.Main
|
screen = Screen.Main()
|
||||||
},
|
},
|
||||||
onBack = null,
|
onBack = null,
|
||||||
)
|
)
|
||||||
@@ -136,7 +154,7 @@ fun AppRoot(
|
|||||||
// shows, so it always refetches.
|
// shows, so it always refetches.
|
||||||
val goToMain = {
|
val goToMain = {
|
||||||
reloadToken++
|
reloadToken++
|
||||||
screen = Screen.Main
|
screen = Screen.Main()
|
||||||
}
|
}
|
||||||
if (screen !is Screen.Main) {
|
if (screen !is Screen.Main) {
|
||||||
BackHandler(onBack = goToMain)
|
BackHandler(onBack = goToMain)
|
||||||
@@ -185,6 +203,9 @@ fun AppRoot(
|
|||||||
reloadToken = reloadToken,
|
reloadToken = reloadToken,
|
||||||
share = share,
|
share = share,
|
||||||
onOpen = { screen = Screen.Session(it) },
|
onOpen = { screen = Screen.Session(it) },
|
||||||
|
onOpenSubagent = { summary, subagent ->
|
||||||
|
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
|
||||||
|
},
|
||||||
onSpawn = { screen = Screen.Spawn },
|
onSpawn = { screen = Screen.Spawn },
|
||||||
onImported = { imported ->
|
onImported = { imported ->
|
||||||
reloadToken++
|
reloadToken++
|
||||||
@@ -192,6 +213,27 @@ fun AppRoot(
|
|||||||
},
|
},
|
||||||
onSettings = { screen = Screen.Settings },
|
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 ->
|
is Screen.Session ->
|
||||||
// Keyed on the id, because a different session is a different screen rather than this
|
// Keyed on the id, because a different session is a different screen rather than this
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): 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 = 3200
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
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()})"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,20 @@ fun debugReport(
|
|||||||
frames: List<String>,
|
frames: List<String>,
|
||||||
accounting: List<String>,
|
accounting: List<String>,
|
||||||
crash: 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 {
|
): String = buildString {
|
||||||
appendLine("ai-app render report")
|
appendLine("ai-app render report")
|
||||||
appendLine(device)
|
appendLine(device)
|
||||||
@@ -141,6 +155,11 @@ fun debugReport(
|
|||||||
appendLine("transcript:")
|
appendLine("transcript:")
|
||||||
transcript.forEach { appendLine(it) }
|
transcript.forEach { appendLine(it) }
|
||||||
appendLine()
|
appendLine()
|
||||||
|
if (phaseFrames.isNotEmpty()) {
|
||||||
|
appendLine("per phase:")
|
||||||
|
phaseFrames.forEach { appendLine(it) }
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
appendLine("frames:")
|
appendLine("frames:")
|
||||||
frames.forEach { appendLine(it) }
|
frames.forEach { appendLine(it) }
|
||||||
appendLine()
|
appendLine()
|
||||||
@@ -152,6 +171,11 @@ fun debugReport(
|
|||||||
appendLine("work since this was last copied:")
|
appendLine("work since this was last copied:")
|
||||||
val work = DebugStats.lines()
|
val work = DebugStats.lines()
|
||||||
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
|
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. */
|
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
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.
|
* A line across the transcript saying what left the session's context.
|
||||||
@@ -47,3 +51,40 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
|||||||
fun ClearedRow(modifier: Modifier = Modifier) {
|
fun ClearedRow(modifier: Modifier = Modifier) {
|
||||||
TranscriptDivider("Context cleared", clearedColor, 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"
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ private const val RESET_EVENT = "reset"
|
|||||||
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
|
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
|
||||||
* saw as the new cursor.
|
* saw as the new cursor.
|
||||||
*/
|
*/
|
||||||
class EventStream(settings: ServerSettings, private val sessionId: String) {
|
class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
|
||||||
private val stream = Sse(settings)
|
private val stream = Sse(settings)
|
||||||
|
|
||||||
fun close() = stream.close()
|
fun close() = stream.close()
|
||||||
@@ -35,7 +35,7 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
|
|||||||
// one and the screen folds the other, and they have to be the same line.
|
// one and the screen folds the other, and they have to be the same line.
|
||||||
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
|
stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
|
||||||
// A named frame carries no payload and a data frame has no name.
|
// A named frame carries no payload and a data frame has no name.
|
||||||
if (name == RESET_EVENT) onReset()
|
if (name == RESET_EVENT) onReset()
|
||||||
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
||||||
|
|||||||
@@ -157,6 +157,18 @@ sealed class SessionEvent {
|
|||||||
*/
|
*/
|
||||||
data object Cleared : SessionEvent()
|
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()
|
data class Error(val message: String) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,6 +273,10 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
trigger = body.optString("trigger").ifEmpty { null },
|
trigger = body.optString("trigger").ifEmpty { null },
|
||||||
)
|
)
|
||||||
"cleared" -> SessionEvent.Cleared
|
"cleared" -> SessionEvent.Cleared
|
||||||
|
"limitReached" ->
|
||||||
|
SessionEvent.LimitReached(
|
||||||
|
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
|
||||||
|
)
|
||||||
"error" -> SessionEvent.Error(body.getString("message"))
|
"error" -> SessionEvent.Error(body.getString("message"))
|
||||||
else -> SessionEvent.Unknown(type)
|
else -> SessionEvent.Unknown(type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ object FrameStats {
|
|||||||
private val gpu = ArrayList<Long>()
|
private val gpu = ArrayList<Long>()
|
||||||
private var since = System.currentTimeMillis()
|
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
|
@Synchronized
|
||||||
fun add(metrics: FrameMetrics) {
|
fun add(metrics: FrameMetrics) {
|
||||||
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
||||||
@@ -69,6 +84,7 @@ object FrameStats {
|
|||||||
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
||||||
it.clear()
|
it.clear()
|
||||||
}
|
}
|
||||||
|
phaseMarks.clear()
|
||||||
since = System.currentTimeMillis()
|
since = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +111,38 @@ object FrameStats {
|
|||||||
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
) + 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. */
|
/** 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
|
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,35 @@ class MainActivity : ComponentActivity() {
|
|||||||
// Transparent status bar on every version; the Surface below paints through underneath it
|
// Transparent status bar on every version; the Surface below paints through underneath it
|
||||||
// and content insets itself. Same reasoning as dev-updater's MainActivity.
|
// and content insets itself. Same reasoning as dev-updater's MainActivity.
|
||||||
enableEdgeToEdge()
|
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
|
// 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
|
// fixed. It was hardcoded to `true`, which was right against the default light surface and
|
||||||
// became unreadable the moment the app wore Catppuccin Mocha.
|
// became unreadable the moment the app wore Catppuccin Mocha.
|
||||||
@@ -147,6 +176,33 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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,
|
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
||||||
// lands here rather than in a second activity instance.
|
// lands here rather than in a second activity instance.
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ fun MainScreen(
|
|||||||
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
|
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
|
||||||
share: ShareRequest? = null,
|
share: ShareRequest? = null,
|
||||||
onOpen: (SessionSummary) -> Unit,
|
onOpen: (SessionSummary) -> Unit,
|
||||||
|
/** Opens one session's subagent, from the expander under its card. */
|
||||||
|
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
|
||||||
onSpawn: () -> Unit,
|
onSpawn: () -> Unit,
|
||||||
onImported: (SessionSummary) -> Unit,
|
onImported: (SessionSummary) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
@@ -139,6 +141,7 @@ fun MainScreen(
|
|||||||
settings = settings,
|
settings = settings,
|
||||||
reloadToken = token,
|
reloadToken = token,
|
||||||
onOpen = onOpen,
|
onOpen = onOpen,
|
||||||
|
onOpenSubagent = onOpenSubagent,
|
||||||
onSpawn = onSpawn,
|
onSpawn = onSpawn,
|
||||||
)
|
)
|
||||||
MainTab.Import ->
|
MainTab.Import ->
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -17,6 +20,7 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -30,6 +34,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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 androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -47,12 +53,40 @@ fun SessionListScreen(
|
|||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
reloadToken: Int,
|
reloadToken: Int,
|
||||||
onOpen: (SessionSummary) -> Unit,
|
onOpen: (SessionSummary) -> Unit,
|
||||||
|
/** Opens one session's subagent, from the expander under its card. */
|
||||||
|
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
|
||||||
onSpawn: () -> Unit,
|
onSpawn: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
||||||
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
|
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
|
// 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,
|
// own card. The two scopes are decided by whether the server answered: it answered and refused,
|
||||||
// so this says nothing about the other rows.
|
// so this says nothing about the other rows.
|
||||||
@@ -84,6 +118,14 @@ fun SessionListScreen(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
|
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
|
loaded
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
@@ -127,6 +169,17 @@ fun SessionListScreen(
|
|||||||
deleting = session.id in deleting,
|
deleting = session.id in deleting,
|
||||||
onOpen = { onOpen(session) },
|
onOpen = { onOpen(session) },
|
||||||
onLongPress = { confirmingDelete = 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))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
@@ -225,7 +278,7 @@ fun SessionListScreen(
|
|||||||
deleteSession(settings, session.id, alsoDeleteForeign)
|
deleteSession(settings, session.id, alsoDeleteForeign)
|
||||||
// After it succeeded, not before: a refused delete leaves the
|
// After it succeeded, not before: a refused delete leaves the
|
||||||
// session exactly as it was, and its transcript with it.
|
// session exactly as it was, and its transcript with it.
|
||||||
transcriptCache.session(session.id).purge()
|
transcriptCache.session(TranscriptAddress(session.id)).purge()
|
||||||
}
|
}
|
||||||
// Only this row, and only what changed. Refetching the list instead
|
// Only this row, and only what changed. Refetching the list instead
|
||||||
// put every other session back through loading and handed the
|
// put every other session back through loading and handed the
|
||||||
@@ -276,6 +329,12 @@ private fun SessionCard(
|
|||||||
deleting: Boolean,
|
deleting: Boolean,
|
||||||
onOpen: () -> Unit,
|
onOpen: () -> Unit,
|
||||||
onLongPress: () -> 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) {
|
BusyItem(label = if (deleting) "deleting" else null) {
|
||||||
Card(
|
Card(
|
||||||
@@ -332,11 +391,105 @@ private fun SessionCard(
|
|||||||
color = MaterialTheme.colorScheme.error,
|
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 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
|
@Composable
|
||||||
fun StatusText(status: String) {
|
fun StatusText(status: String) {
|
||||||
val (label, color) =
|
val (label, color) =
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.activity.result.PickVisualMediaRequest
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -64,12 +65,15 @@ import androidx.compose.runtime.snapshots.Snapshot
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.TextRange
|
import androidx.compose.ui.text.TextRange
|
||||||
@@ -216,16 +220,33 @@ fun SessionScreen(
|
|||||||
share: ShareRequest? = null,
|
share: ShareRequest? = null,
|
||||||
/** Said once [share] has been attached here, so it is not attached again. */
|
/** Said once [share] has been attached here, so it is not attached again. */
|
||||||
onShareTaken: () -> Unit = {},
|
onShareTaken: () -> Unit = {},
|
||||||
|
/**
|
||||||
|
* Draws this screen read-only, on a subagent's own transcript instead of the session's.
|
||||||
|
*
|
||||||
|
* A subagent has no process and no controls of its own -- see SUBAGENTS.md's "Phone" -- so
|
||||||
|
* every gate below keyed on this switches off the composer, the files button, the settings cog,
|
||||||
|
* the usage bar and notifications, while everything that draws a transcript (paging, cache,
|
||||||
|
* selection, images, the status row, stream reconnects) is reused unchanged, pointed at
|
||||||
|
* [address] instead of the session's own.
|
||||||
|
*/
|
||||||
|
subagent: SubagentSummary? = null,
|
||||||
) {
|
) {
|
||||||
DebugStats.count("session screen recomposed")
|
DebugStats.count("session screen recomposed")
|
||||||
|
val isSubagent = subagent != null
|
||||||
|
val address = TranscriptAddress(summary.id, subagent?.id)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val topEdgeHeld = remember { TopEdgeHold() }
|
val topEdgeHeld = remember { TopEdgeHold() }
|
||||||
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
||||||
var status by remember { mutableStateOf(summary.status) }
|
var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
|
||||||
// Seeded from the row this screen was opened from, so a conversation already under way says how
|
// Seeded from the row this screen was opened from, so a conversation already under way says how
|
||||||
// much it is holding before any turn happens here. Null is "nobody has measured it", which is a
|
// much it is holding before any turn happens here. Null is "nobody has measured it", which is a
|
||||||
// different answer from an empty context and is drawn differently.
|
// different answer from an empty context and is drawn differently.
|
||||||
var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) }
|
//
|
||||||
|
// A subagent has no context measurement of its own, so it always starts unmeasured rather than
|
||||||
|
// borrowing the parent session's figure -- see UI_RULES on not showing an inferred value as one
|
||||||
|
// that was measured.
|
||||||
|
var contextTokens by
|
||||||
|
remember(address) { mutableStateOf(if (isSubagent) null else summary.contextTokens) }
|
||||||
// When the current compaction started. The moment comes off the `compacting` status event
|
// When the current compaction started. The moment comes off the `compacting` status event
|
||||||
// itself -- the server timestamps every transcript line -- rather than off this device noticing
|
// itself -- the server timestamps every transcript line -- rather than off this device noticing
|
||||||
// one, which is what makes it survive leaving the session and reopening it.
|
// one, which is what makes it survive leaving the session and reopening it.
|
||||||
@@ -241,7 +262,13 @@ fun SessionScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
// Seeded from what was left in the box last time and written back on every keystroke, so
|
// Seeded from what was left in the box last time and written back on every keystroke, so
|
||||||
// leaving the screen does not throw away a half-typed message. See `Drafts.kt`.
|
// leaving the screen does not throw away a half-typed message. See `Drafts.kt`.
|
||||||
var input by remember(summary.id) { mutableStateOf(atEnd(loadDraft(context, summary.id))) }
|
//
|
||||||
|
// A subagent has no box to type into, so it never touches a draft at all -- not this session's,
|
||||||
|
// which is what reading one keyed only by `summary.id` would do here.
|
||||||
|
var input by
|
||||||
|
remember(summary.id) {
|
||||||
|
mutableStateOf(if (isSubagent) atEnd("") else atEnd(loadDraft(context, summary.id)))
|
||||||
|
}
|
||||||
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
|
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
|
||||||
// makes the session re-read the whole conversation.
|
// makes the session re-read the whole conversation.
|
||||||
var pendingModel by remember { mutableStateOf<String?>(null) }
|
var pendingModel by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -294,27 +321,26 @@ fun SessionScreen(
|
|||||||
// Reload throws away what it was reading from.
|
// Reload throws away what it was reading from.
|
||||||
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||||
val source =
|
val source =
|
||||||
remember(summary.id, epoch) {
|
remember(address, epoch) { TranscriptSource(settings, address, cache.session(address)) }
|
||||||
TranscriptSource(settings, summary.id, cache.session(summary.id))
|
|
||||||
}
|
|
||||||
// Whether the cached tail has been shown to still be the server's own line. Nothing is resumed
|
// Whether the cached tail has been shown to still be the server's own line. Nothing is resumed
|
||||||
// from a cached cursor until it has, and a probe that could not be made leaves this false for
|
// from a cached cursor until it has, and a probe that could not be made leaves this false for
|
||||||
// the stream loop to try again.
|
// the stream loop to try again.
|
||||||
var probePassed by remember(summary.id, epoch) { mutableStateOf(false) }
|
var probePassed by remember(address, epoch) { mutableStateOf(false) }
|
||||||
// Whether the opening effect is still settling that question. It draws the cached rows and
|
// Whether the opening effect is still settling that question. It draws the cached rows and
|
||||||
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
|
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
|
||||||
// below waits for this rather than for `ready`, or it asks the same question twice.
|
// below waits for this rather than for `ready`, or it asks the same question twice.
|
||||||
var probing by remember(summary.id, epoch) { mutableStateOf(true) }
|
var probing by remember(address, epoch) { mutableStateOf(true) }
|
||||||
// The oldest sequence number loaded, and whether there is more behind it. Paging backwards is
|
// The oldest sequence number loaded, and whether there is more behind it. Paging backwards is
|
||||||
// what keeps opening a long session cheap.
|
// what keeps opening a long session cheap.
|
||||||
var oldestSeq by remember { mutableLongStateOf(0L) }
|
var oldestSeq by remember { mutableLongStateOf(0L) }
|
||||||
// Where this session was last being read, from this device's own store. Read once, because the
|
// Where this transcript was last being read, from this device's own store, keyed by the address
|
||||||
// answer stops being interesting the moment the list is on screen.
|
// rather than the session id so a subagent's saved position cannot collide with its session's.
|
||||||
val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) }
|
// Read once, because the answer stops being interesting the moment the list is on screen.
|
||||||
|
val savedAnchor = remember(address, epoch) { loadScrollAnchor(context, address.cachePath) }
|
||||||
// Whether the saved position is still being put back. Nothing is drawn while it is: opening at
|
// Whether the saved position is still being put back. Nothing is drawn while it is: opening at
|
||||||
// the newest end and then travelling to the anchor is exactly the journey a reader must never
|
// the newest end and then travelling to the anchor is exactly the journey a reader must never
|
||||||
// see.
|
// see.
|
||||||
var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) }
|
var restoring by remember(address, epoch) { mutableStateOf(savedAnchor != null) }
|
||||||
// Messages the server has taken and the session has not read yet, by the id that will resolve
|
// Messages the server has taken and the session has not read yet, by the id that will resolve
|
||||||
// them. From the event stream rather than from what this screen sent, so they survive leaving
|
// them. From the event stream rather than from what this screen sent, so they survive leaving
|
||||||
// the session -- and a message sent from another device is drawn waiting on this one too.
|
// the session -- and a message sent from another device is drawn waiting on this one too.
|
||||||
@@ -327,11 +353,22 @@ fun SessionScreen(
|
|||||||
var loadingHistory by remember { mutableStateOf(false) }
|
var loadingHistory by remember { mutableStateOf(false) }
|
||||||
var ready by remember { mutableStateOf(false) }
|
var ready by remember { mutableStateOf(false) }
|
||||||
// Replies parsed ahead of the rows that draw them; see [ParsedReplies].
|
// Replies parsed ahead of the rows that draw them; see [ParsedReplies].
|
||||||
val replies = remember(summary.id) { ParsedReplies() }
|
val replies = remember(address) { ParsedReplies() }
|
||||||
// Keyed like everything else describing one session's transcript. `rememberLazyListState` saves
|
// Keyed like everything else describing one transcript. `rememberLazyListState` saves through
|
||||||
// through `rememberSaveable`, and this screen restores by its own anchor instead -- two
|
// `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
|
||||||
// restores would fight over the first frame.
|
// fight over the first frame.
|
||||||
val listState = remember(summary.id) { LazyListState() }
|
val listState = remember(address) { LazyListState() }
|
||||||
|
// The list's own fling path -- what a real flick decays through -- captured here so BenchRun's
|
||||||
|
// fling phase can drive `LazyListState.scroll` through exactly the `FlingBehavior` this
|
||||||
|
// screen's
|
||||||
|
// `TranscriptList` already uses by not overriding it (its `LazyColumn` takes no `flingBehavior`
|
||||||
|
// argument, so this is the same default it gets).
|
||||||
|
val flingBehavior = ScrollableDefaults.flingBehavior()
|
||||||
|
// Where BenchRun's type phase focuses before it types, and the view it toggles the keyboard on
|
||||||
|
// -- both bench-only, but cheap enough (a remembered object, a CompositionLocal read) to hold
|
||||||
|
// unconditionally rather than behind a second code path only the bench build compiles.
|
||||||
|
val composerFocus = remember { FocusRequester() }
|
||||||
|
val view = LocalView.current
|
||||||
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
|
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
|
||||||
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
|
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
|
||||||
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
|
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
|
||||||
@@ -637,7 +674,7 @@ fun SessionScreen(
|
|||||||
// ended and carries live events only. The window comes from this phone's own copy when there is
|
// ended and carries live events only. The window comes from this phone's own copy when there is
|
||||||
// one, and then costs a single request to check that the server's transcript is still the one
|
// one, and then costs a single request to check that the server's transcript is still the one
|
||||||
// it came from. See TRANSCRIPT_CACHE.md.
|
// it came from. See TRANSCRIPT_CACHE.md.
|
||||||
LaunchedEffect(summary.id, epoch) {
|
LaunchedEffect(address, epoch) {
|
||||||
/**
|
/**
|
||||||
* One opening window onto the screen, whichever side it came from.
|
* One opening window onto the screen, whichever side it came from.
|
||||||
*
|
*
|
||||||
@@ -667,11 +704,16 @@ fun SessionScreen(
|
|||||||
// A replay is as old as the last visit; the row this screen was opened from was
|
// A replay is as old as the last visit; the row this screen was opened from was
|
||||||
// fetched moments ago. So the transcript comes from the cache and everything that
|
// fetched moments ago. So the transcript comes from the cache and everything that
|
||||||
// is not the transcript comes from the summary -- otherwise a session that finished
|
// is not the transcript comes from the summary -- otherwise a session that finished
|
||||||
// an hour ago opens saying "working" until the stream connects.
|
// an hour ago opens saying "working" until the stream connects. A subagent's status
|
||||||
status = summary.status
|
// comes from its own summary, never the parent session's: they are two different
|
||||||
|
// things running or not, and the parent's model and permission mode do not apply to
|
||||||
|
// it at all.
|
||||||
|
status = subagent?.status ?: summary.status
|
||||||
|
if (!isSubagent) {
|
||||||
model = summary.model
|
model = summary.model
|
||||||
permissionMode = summary.permissionMode ?: "auto"
|
permissionMode = summary.permissionMode ?: "auto"
|
||||||
if (summary.status != "compacting") compactingSince = null
|
}
|
||||||
|
if (status != "compacting") compactingSince = null
|
||||||
// Nothing to put back, so these rows are the screen and the probe can return under
|
// Nothing to put back, so these rows are the screen and the probe can return under
|
||||||
// them. A restore still has history to fetch and is gated below.
|
// them. A restore still has history to fetch and is gated below.
|
||||||
if (savedAnchor == null) ready = true
|
if (savedAnchor == null) ready = true
|
||||||
@@ -798,7 +840,7 @@ fun SessionScreen(
|
|||||||
// at the top on their return. Switching apps is a choice somebody made, not a fault to report.
|
// at the top on their return. Switching apps is a choice somebody made, not a fault to report.
|
||||||
// Stopping the stream deliberately makes the drop a close rather than an error, and resuming
|
// Stopping the stream deliberately makes the drop a close rather than an error, and resuming
|
||||||
// reconnects from the same cursor.
|
// reconnects from the same cursor.
|
||||||
LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) {
|
LaunchedEffect(address, ready, epoch, lifecycleOwner) {
|
||||||
if (!ready) return@LaunchedEffect
|
if (!ready) return@LaunchedEffect
|
||||||
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the
|
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the
|
||||||
// cursor under them is still the server's, so `ready` is no longer the whole gate. Without
|
// cursor under them is still the server's, so `ready` is no longer the whole gate. Without
|
||||||
@@ -868,10 +910,14 @@ fun SessionScreen(
|
|||||||
// The screen going away entirely, which the lifecycle scope above does not cover: a composable
|
// The screen going away entirely, which the lifecycle scope above does not cover: a composable
|
||||||
// can leave the composition while the activity stays started. Keyed on the epoch as well, so
|
// can leave the composition while the activity stays started. Keyed on the epoch as well, so
|
||||||
// Reload's replacement source is the one a later disposal closes.
|
// Reload's replacement source is the one a later disposal closes.
|
||||||
DisposableEffect(summary.id, epoch) { onDispose { source.close() } }
|
DisposableEffect(address, epoch) { onDispose { source.close() } }
|
||||||
|
|
||||||
// Nothing gets announced about the session somebody is reading; see NotificationService.
|
// Nothing gets announced about the session somebody is reading; see NotificationService.
|
||||||
// RESUMED rather than STARTED because "looking at it" means the foreground.
|
// RESUMED rather than STARTED because "looking at it" means the foreground.
|
||||||
|
//
|
||||||
|
// Not for a subagent: it has no notifications of its own, and it is not the session this would
|
||||||
|
// otherwise mark as being read.
|
||||||
|
if (!isSubagent) {
|
||||||
LaunchedEffect(summary.id, lifecycleOwner) {
|
LaunchedEffect(summary.id, lifecycleOwner) {
|
||||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
NotificationService.showing(context, summary.id)
|
NotificationService.showing(context, summary.id)
|
||||||
@@ -882,6 +928,7 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather than
|
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather than
|
||||||
// paced out: they are at the bottom, which is the one place the list is allowed to follow new
|
// paced out: they are at the bottom, which is the one place the list is allowed to follow new
|
||||||
@@ -924,7 +971,7 @@ fun SessionScreen(
|
|||||||
val (index, offset, awayFromNewest) = settled
|
val (index, offset, awayFromNewest) = settled
|
||||||
saveScrollAnchor(
|
saveScrollAnchor(
|
||||||
context,
|
context,
|
||||||
summary.id,
|
address.cachePath,
|
||||||
// Nothing to restore at the newest end, which is where a session with no anchor
|
// Nothing to restore at the newest end, which is where a session with no anchor
|
||||||
// opens anyway. One *before* the index, because item zero is the "below" slot.
|
// opens anyway. One *before* the index, because item zero is the "below" slot.
|
||||||
if (!awayFromNewest) null
|
if (!awayFromNewest) null
|
||||||
@@ -947,7 +994,7 @@ fun SessionScreen(
|
|||||||
//
|
//
|
||||||
// There is no correction beside this one. Following the newest message is not an effect: the
|
// There is no correction beside this one. Following the newest message is not an effect: the
|
||||||
// list is reversed, so an arriving message extends the end the viewport is pinned to.
|
// list is reversed, so an arriving message extends the end the viewport is pinned to.
|
||||||
val unitSizes = remember(summary.id) { HashMap<Any, Int>() }
|
val unitSizes = remember(address) { HashMap<Any, Int>() }
|
||||||
LaunchedEffect(listState, moreHistory) {
|
LaunchedEffect(listState, moreHistory) {
|
||||||
snapshotFlow { listState.layoutInfo }
|
snapshotFlow { listState.layoutInfo }
|
||||||
.collect { info ->
|
.collect { info ->
|
||||||
@@ -983,6 +1030,8 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only for the model picker, which a subagent does not have.
|
||||||
|
if (!isSubagent) {
|
||||||
LaunchedEffect(summary.setupName, summary.provider) {
|
LaunchedEffect(summary.setupName, summary.provider) {
|
||||||
offeredModels =
|
offeredModels =
|
||||||
try {
|
try {
|
||||||
@@ -995,10 +1044,12 @@ fun SessionScreen(
|
|||||||
.orEmpty()
|
.orEmpty()
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Not worth reporting: the picker simply has nothing to offer, which is visible.
|
// Not worth reporting: the picker simply has nothing to offer, which is
|
||||||
|
// visible.
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asks the server to take back a message the session has not read yet.
|
* Asks the server to take back a message the session has not read yet.
|
||||||
@@ -1148,8 +1199,9 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One poll for the machines' limits, read by everything on this screen that reports them.
|
// One poll for the machines' limits, read by everything on this screen that reports them.
|
||||||
val usageFeed = rememberUsageFeed(settings)
|
// Nothing meters a subagent -- it has no account of its own -- so it never starts this poll.
|
||||||
val usage = usageFeed.forSetup(summary.setup)
|
val usageFeed = if (isSubagent) null else rememberUsageFeed(settings)
|
||||||
|
val usage = usageFeed?.forSession(summary) ?: SessionUsage.NotMetered
|
||||||
RecordFrames()
|
RecordFrames()
|
||||||
var usageOpen by remember { mutableStateOf(false) }
|
var usageOpen by remember { mutableStateOf(false) }
|
||||||
var settingsOpen by remember { mutableStateOf(false) }
|
var settingsOpen by remember { mutableStateOf(false) }
|
||||||
@@ -1191,7 +1243,12 @@ fun SessionScreen(
|
|||||||
// the bench scripts keep working when this moves again. They pressed it at a hand-measured
|
// the bench scripts keep working when this moves again. They pressed it at a hand-measured
|
||||||
// coordinate until 2026-09-03, and anything that moved the header made that tap land on
|
// coordinate until 2026-09-03, and anything that moved the header made that tap land on
|
||||||
// whatever now sat there -- reporting a number that was never measured.
|
// whatever now sat there -- reporting a number that was never measured.
|
||||||
val copyRenderReport = {
|
// Shared by the ordinary "Copy" button and (bench build only) "Run benchmark": what differs
|
||||||
|
// between them is only whether there is a [extra] section, built by BenchRun.run beforehand --
|
||||||
|
// everything about assembling, copying and logging the report is exactly the same act either
|
||||||
|
// way, and a second copy of it beside `onRunBenchmark` below would be the two silently
|
||||||
|
// disagreeing about what "the report" contains the first time either one changed.
|
||||||
|
fun buildAndCopyReport(extra: List<String> = emptyList()) {
|
||||||
val report =
|
val report =
|
||||||
debugReport(
|
debugReport(
|
||||||
device =
|
device =
|
||||||
@@ -1213,6 +1270,11 @@ fun SessionScreen(
|
|||||||
accounting =
|
accounting =
|
||||||
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
||||||
crash = lastCrash(context),
|
crash = lastCrash(context),
|
||||||
|
extra = extra,
|
||||||
|
// Empty outside a BenchRun.run pass -- copyRenderReport's own reset below clears
|
||||||
|
// the
|
||||||
|
// marks along with everything else, so an ordinary copy never has any to show.
|
||||||
|
phaseFrames = FrameStats.phaseLines(context.refreshHz()),
|
||||||
)
|
)
|
||||||
context.copyToClipboard("ai-app render report", report)
|
context.copyToClipboard("ai-app render report", report)
|
||||||
// Also to the log, so a session driving the app over adb can read the same report the
|
// Also to the log, so a session driving the app over adb can read the same report the
|
||||||
@@ -1226,6 +1288,29 @@ fun SessionScreen(
|
|||||||
DebugStats.reset()
|
DebugStats.reset()
|
||||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
val copyRenderReport = { buildAndCopyReport() }
|
||||||
|
// Bench build only: P0's scripted fling/stream/type/keyboard benchmark (BenchRun.kt), against
|
||||||
|
// the fixture session opened below instead of a real server. Null everywhere else -- see
|
||||||
|
// [SessionSettingsDialog]'s onRunBenchmark.
|
||||||
|
val runBenchmark: (() -> Unit)? =
|
||||||
|
if (BuildConfig.FIXTURE_MODE) {
|
||||||
|
{
|
||||||
|
settingsOpen = false
|
||||||
|
scope.launch {
|
||||||
|
val extra =
|
||||||
|
BenchRun.run(
|
||||||
|
context = context,
|
||||||
|
scope = scope,
|
||||||
|
listState = listState,
|
||||||
|
flingBehavior = flingBehavior,
|
||||||
|
composerFocus = composerFocus,
|
||||||
|
setComposerText = { text -> input = atEnd(text) },
|
||||||
|
view = view,
|
||||||
|
)
|
||||||
|
buildAndCopyReport(extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else null
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Row(
|
Row(
|
||||||
@@ -1236,20 +1321,35 @@ fun SessionScreen(
|
|||||||
// A ring's worth, which is what the arrow already keeps on its other three sides.
|
// A ring's worth, which is what the arrow already keeps on its other three sides.
|
||||||
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
|
// A subagent's own title, with the session's beneath it in a smaller style --
|
||||||
|
// the header says whose conversation this is as well as what it is. Otherwise
|
||||||
|
// just the session's title, as before.
|
||||||
|
if (subagent != null) {
|
||||||
|
Text(subagent.title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
// Machine first, then what runs on it -- the same order and the same wording
|
// Machine first, then what runs on it -- the same order and the same
|
||||||
// everywhere this pair appears, so it reads as one fact rather than two
|
// wording everywhere this pair appears, so it reads as one fact rather than
|
||||||
// sentences with different grammar.
|
// two sentences with different grammar.
|
||||||
//
|
//
|
||||||
// No model. The picker in the footer already shows what this session is set to,
|
// No model. The picker in the footer already shows what this session is set
|
||||||
// and showing it twice means two things to keep in step -- they disagreed for a
|
// to, and showing it twice means two things to keep in step -- they
|
||||||
// moment on every model change.
|
// disagreed for a moment on every model change.
|
||||||
Text(
|
Text(
|
||||||
"${summary.setupName} · ${summary.provider}",
|
"${summary.setupName} · ${summary.provider}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// None of this is a subagent's: it has no files of its own to browse, no settings,
|
||||||
|
// and nothing meters it -- see SUBAGENTS.md's "Phone".
|
||||||
|
//
|
||||||
// Beside the provider it reports on, which is the line directly to its left. Its
|
// Beside the provider it reports on, which is the line directly to its left. Its
|
||||||
// real home is this provider's settings, which do not exist yet. A session on a
|
// real home is this provider's settings, which do not exist yet. A session on a
|
||||||
// provider with no such service gets an honest "unavailable" rather than a hidden
|
// provider with no such service gets an honest "unavailable" rather than a hidden
|
||||||
@@ -1264,6 +1364,7 @@ fun SessionScreen(
|
|||||||
// Usage, files, settings -- widest scope first, narrowing to the right, so the cog
|
// Usage, files, settings -- widest scope first, narrowing to the right, so the cog
|
||||||
// stays at the end where every other screen keeps it. Asked for in this order by
|
// stays at the end where every other screen keeps it. Asked for in this order by
|
||||||
// Iris on 2026-09-03.
|
// Iris on 2026-09-03.
|
||||||
|
if (!isSubagent) {
|
||||||
Row {
|
Row {
|
||||||
GlyphButton(
|
GlyphButton(
|
||||||
USAGE_GLYPH,
|
USAGE_GLYPH,
|
||||||
@@ -1281,25 +1382,29 @@ fun SessionScreen(
|
|||||||
FilesTarget(
|
FilesTarget(
|
||||||
setup = summary.setup,
|
setup = summary.setup,
|
||||||
setupName = summary.setupName,
|
setupName = summary.setupName,
|
||||||
// Where this session works, and the machine's own home when it
|
// Where this session works, and the machine's own home when
|
||||||
// was never given a directory -- resolved there rather than
|
// it was never given a directory -- resolved there rather
|
||||||
// guessed at here, since this app does not know that home.
|
// than guessed at here, since this app does not know that
|
||||||
|
// home.
|
||||||
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
|
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// What it opens is about this session, so it sits at the end of the session's
|
// What it opens is about this session, so it sits at the end of the
|
||||||
// own row. A cog and not a word because there will be more, and a bar of words
|
// session's own row. A cog and not a word because there will be more, and a
|
||||||
// has nowhere to put it.
|
// bar of words has nowhere to put it.
|
||||||
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
|
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Under the header, above everything the session itself says: it is a fact about the
|
// Under the header, above everything the session itself says: it is a fact about the
|
||||||
// machine rather than a turn in the conversation, and it is the number that decides
|
// machine rather than a turn in the conversation, and it is the number that decides
|
||||||
// whether to keep going.
|
// whether to keep going. Nothing meters a subagent.
|
||||||
|
if (!isSubagent) {
|
||||||
SessionUsageBar(usage)
|
SessionUsageBar(usage)
|
||||||
|
}
|
||||||
|
|
||||||
(streamError ?: actionError)?.let { message ->
|
(streamError ?: actionError)?.let { message ->
|
||||||
Text(
|
Text(
|
||||||
@@ -1545,6 +1650,7 @@ fun SessionScreen(
|
|||||||
is TranscriptItem.ClearedNote -> ClearedRow()
|
is TranscriptItem.ClearedNote -> ClearedRow()
|
||||||
is TranscriptItem.CompactedNote ->
|
is TranscriptItem.CompactedNote ->
|
||||||
CompactedRow(item)
|
CompactedRow(item)
|
||||||
|
is TranscriptItem.LimitNote -> LimitRow(item)
|
||||||
// Never reached: a peer message is flattened into
|
// Never reached: a peer message is flattened into
|
||||||
// its own units. Here because a `when` over the
|
// its own units. Here because a `when` over the
|
||||||
// item kinds has to stay exhaustive.
|
// item kinds has to stay exhaustive.
|
||||||
@@ -1643,24 +1749,34 @@ fun SessionScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept for a subagent -- see SUBAGENTS.md's "Phone" -- with the wording that turns
|
||||||
|
// "exited" into "finished" for one, since it has no process to leave running or stop.
|
||||||
SessionStatusRow(
|
SessionStatusRow(
|
||||||
status = status,
|
status = status,
|
||||||
compactingFor = compactingFor,
|
compactingFor = compactingFor,
|
||||||
contextTokens = contextTokens,
|
contextTokens = contextTokens,
|
||||||
|
subagent = isSubagent,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Between the transcript and the box: above what is being typed, so the list does not
|
// Everything from here down is the composer: a subagent cannot be messaged, so none of
|
||||||
// cover the thing the command is about, and below everything that explains it.
|
// it applies -- see SUBAGENTS.md's "Phone".
|
||||||
|
if (!isSubagent) {
|
||||||
|
// Between the transcript and the box: above what is being typed, so the list does
|
||||||
|
// not cover the thing the command is about, and below everything that explains it.
|
||||||
CommandSuggestions(
|
CommandSuggestions(
|
||||||
// Nothing to suggest about a suggestion that was just taken. `/compact` is a whole
|
// Nothing to suggest about a suggestion that was just taken. `/compact` is a
|
||||||
// command *and* a prefix of itself, so picking it left the list standing there with
|
// whole command *and* a prefix of itself, so picking it left the list standing
|
||||||
// the one row already chosen. Held by what was picked rather than by a flag, so
|
// there with the one row already chosen. Held by what was picked rather than by
|
||||||
// typing anything else brings the list back without a second thing to reset.
|
// a flag, so typing anything else brings the list back without a second thing
|
||||||
commands = if (input.text == picked) emptyList() else suggestedCommands(input.text),
|
// to
|
||||||
|
// reset.
|
||||||
|
commands =
|
||||||
|
if (input.text == picked) emptyList() else suggestedCommands(input.text),
|
||||||
onPick = { command ->
|
onPick = { command ->
|
||||||
// At the end of what was inserted, which is where the reader carries on typing:
|
// At the end of what was inserted, which is where the reader carries on
|
||||||
// a command with an argument is put in the box half-written, and a cursor left
|
// typing: a command with an argument is put in the box half-written, and a
|
||||||
// at the front makes the next keystroke the first character of "/rename".
|
// cursor left at the front makes the next keystroke the first character of
|
||||||
|
// "/rename".
|
||||||
input = atEnd(command.typed())
|
input = atEnd(command.typed())
|
||||||
picked = command.typed()
|
picked = command.typed()
|
||||||
},
|
},
|
||||||
@@ -1669,11 +1785,14 @@ fun SessionScreen(
|
|||||||
// Always enabled -- a send while the session is running becomes a steering message
|
// Always enabled -- a send while the session is running becomes a steering message
|
||||||
// injected at the next tool boundary, which is the point of the whole app.
|
// injected at the next tool boundary, which is the point of the whole app.
|
||||||
//
|
//
|
||||||
// The field gets a row of its own, above the buttons: sharing one put the full width
|
// The field gets a row of its own, above the buttons: sharing one put the full
|
||||||
// behind three controls, so the thing being typed into was the narrowest on the row.
|
// width
|
||||||
|
// behind three controls, so the thing being typed into was the narrowest on the
|
||||||
|
// row.
|
||||||
Column(Modifier.fillMaxWidth().padding(8.dp)) {
|
Column(Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
// Directly above the box they will be sent from, so what is attached is visible
|
// Directly above the box they will be sent from, so what is attached is visible
|
||||||
// rather than counted: the "+2" on the button below said how many and never which.
|
// rather than counted: the "+2" on the button below said how many and never
|
||||||
|
// which.
|
||||||
PendingAttachments(
|
PendingAttachments(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
sessionId = summary.id,
|
sessionId = summary.id,
|
||||||
@@ -1686,9 +1805,12 @@ fun SessionScreen(
|
|||||||
input = it
|
input = it
|
||||||
saveDraft(context, summary.id, it.text)
|
saveDraft(context, summary.id, it.text)
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// BenchRun's type phase requests focus on this exact field
|
||||||
// No longer "(+image)": the images are on screen above this, and a placeholder
|
// (`composerFocus`)
|
||||||
// saying so said it in words beside the thing itself.
|
// so it types through the real composer rather than a stand-in.
|
||||||
|
modifier = Modifier.fillMaxWidth().focusRequester(composerFocus),
|
||||||
|
// No longer "(+image)": the images are on screen above this, and a
|
||||||
|
// placeholder saying so said it in words beside the thing itself.
|
||||||
placeholder = { Text("Message") },
|
placeholder = { Text("Message") },
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
)
|
)
|
||||||
@@ -1696,17 +1818,19 @@ fun SessionScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
// Photo or file, asked here rather than by two buttons: the row is full, and
|
// Photo or file, asked here rather than by two buttons: the row is full,
|
||||||
|
// and
|
||||||
// attaching is one action whichever picker answers it.
|
// attaching is one action whichever picker answers it.
|
||||||
var attaching by remember { mutableStateOf(false) }
|
var attaching by remember { mutableStateOf(false) }
|
||||||
Box {
|
Box {
|
||||||
// Just "+". The count it used to carry was standing in for showing them.
|
// Just "+". The count it used to carry was standing in for showing
|
||||||
|
// them.
|
||||||
BubbleButton(onClick = { attaching = true }) { Text("+") }
|
BubbleButton(onClick = { attaching = true }) { Text("+") }
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = attaching,
|
expanded = attaching,
|
||||||
onDismissRequest = { attaching = false },
|
onDismissRequest = { attaching = false },
|
||||||
// See PickerButton: without this the menu opens a status bar's height
|
// See PickerButton: without this the menu opens a status bar's
|
||||||
// away from the button in an edge-to-edge activity.
|
// height away from the button in an edge-to-edge activity.
|
||||||
properties = PopupProperties(clippingEnabled = false),
|
properties = PopupProperties(clippingEnabled = false),
|
||||||
shape = BubbleMenuShape,
|
shape = BubbleMenuShape,
|
||||||
) {
|
) {
|
||||||
@@ -1730,11 +1854,11 @@ fun SessionScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The settings share what is left after the actions have taken what they need.
|
// The settings share what is left after the actions have taken what they
|
||||||
// A Row hands out intrinsic widths in order and clips whatever runs past the
|
// need. A Row hands out intrinsic widths in order and clips whatever runs
|
||||||
// edge, so with these laid out first the arrival of Stop pushed Send off the
|
// past the edge, so with these laid out first the arrival of Stop pushed
|
||||||
// screen entirely -- the app's central control, gone at the moment it is most
|
// Send off the screen entirely -- the app's central control, gone at the
|
||||||
// in use.
|
// moment it is most in use.
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@@ -1742,16 +1866,18 @@ fun SessionScreen(
|
|||||||
if (offeredModels.isNotEmpty()) {
|
if (offeredModels.isNotEmpty()) {
|
||||||
PickerButton(
|
PickerButton(
|
||||||
current = modelLabel(model),
|
current = modelLabel(model),
|
||||||
// What the machine offers, plus the state a session is in when it
|
// What the machine offers, plus the state a session is in when
|
||||||
// has chosen none of them. The button has always been able to say
|
// it has chosen none of them. The button has always been able
|
||||||
// "default"; until this the list could not, so leaving it was a
|
// to
|
||||||
// one-way trip.
|
// say "default"; until this the list could not, so leaving it
|
||||||
|
// was a one-way trip.
|
||||||
options = listOf(DEFAULT_MODEL) + offeredModels,
|
options = listOf(DEFAULT_MODEL) + offeredModels,
|
||||||
// Not set here. The button follows what the session reports it is
|
// Not set here. The button follows what the session reports it
|
||||||
// set to, which arrives a moment later and is sometimes a different
|
// is set to, which arrives a moment later and is sometimes a
|
||||||
// answer -- a name the CLI resolved, or no change at all on a
|
// different answer -- a name the CLI resolved, or no change at
|
||||||
// provider whose model is fixed. Asked about first, unless there is
|
// all on a provider whose model is fixed. Asked about first,
|
||||||
// nothing to lose by it -- see [ModelSwitchWarning].
|
// unless there is nothing to lose by it -- see
|
||||||
|
// [ModelSwitchWarning].
|
||||||
onPick = { chosen ->
|
onPick = { chosen ->
|
||||||
if (
|
if (
|
||||||
modelLabel(chosen) == modelLabel(model) ||
|
modelLabel(chosen) == modelLabel(model) ||
|
||||||
@@ -1772,14 +1898,16 @@ fun SessionScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// The same filled shape as the button beside it, not an outlined one: these are
|
// The same filled shape as the button beside it, not an outlined one: these
|
||||||
// two things you can do about the session, and weighting one as secondary said
|
// are two things you can do about the session, and weighting one as
|
||||||
// they were a primary action and its qualifier. What separates them is the
|
// secondary said they were a primary action and its qualifier. What
|
||||||
// colour and the mark, which is what they mean.
|
// separates them is the colour and the mark, which is what they mean.
|
||||||
//
|
//
|
||||||
// Always here, rather than arriving with the turn as it used to. A control that
|
// Always here, rather than arriving with the turn as it used to. A control
|
||||||
// comes and goes makes its own presence the signal, and a button always in the
|
// that comes and goes makes its own presence the signal, and a button
|
||||||
// same place also cannot push Send off the end of the row by turning up.
|
// always
|
||||||
|
// in the same place also cannot push Send off the end of the row by turning
|
||||||
|
// up.
|
||||||
val process =
|
val process =
|
||||||
when {
|
when {
|
||||||
running -> ProcessAction.Pause
|
running -> ProcessAction.Pause
|
||||||
@@ -1799,18 +1927,21 @@ fun SessionScreen(
|
|||||||
Glyph(
|
Glyph(
|
||||||
process.glyph,
|
process.glyph,
|
||||||
colour = LocalContentColor.current,
|
colour = LocalContentColor.current,
|
||||||
modifier = Modifier.semantics { contentDescription = process.label },
|
modifier =
|
||||||
|
Modifier.semantics { contentDescription = process.label },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
// The paper plane, with a clock on it while a turn is in flight: sending then
|
// The paper plane, with a clock on it while a turn is in flight: sending
|
||||||
// queues the message for the next tool boundary rather than starting a turn of
|
// then queues the message for the next tool boundary rather than starting a
|
||||||
// its own, and the two have to be told apart at a glance. The label says the
|
// turn of its own, and the two have to be told apart at a glance. The label
|
||||||
// same thing to a screen reader.
|
// says the same thing to a screen reader.
|
||||||
//
|
//
|
||||||
// Disabled while there is nothing to send, rather than pressable and silent:
|
// Disabled while there is nothing to send, rather than pressable and
|
||||||
// `send` has always returned early on an empty composer, so the button promised
|
// silent:
|
||||||
// something it would not do. Disabled and not hidden, for the reason above.
|
// `send` has always returned early on an empty composer, so the button
|
||||||
|
// promised something it would not do. Disabled and not hidden, for the
|
||||||
|
// reason above.
|
||||||
Button(
|
Button(
|
||||||
onClick = { send() },
|
onClick = { send() },
|
||||||
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
|
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
|
||||||
@@ -1827,12 +1958,13 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Beside the other two dialogs, and outside the list for the same reason as them: what is open
|
// Beside the other two dialogs, and outside the list for the same reason as them: what is open
|
||||||
// is the screen's business rather than any row's. See [SessionImageViewer].
|
// is the screen's business rather than any row's. See [SessionImageViewer].
|
||||||
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
||||||
if (usageOpen) {
|
if (usageOpen) {
|
||||||
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false })
|
usageFeed?.let { UsageDialog(feed = it, onDismiss = { usageOpen = false }) }
|
||||||
}
|
}
|
||||||
if (settingsOpen) {
|
if (settingsOpen) {
|
||||||
// Measured when the dialog opens rather than kept up to date: what the reader is being told
|
// Measured when the dialog opens rather than kept up to date: what the reader is being told
|
||||||
@@ -1846,6 +1978,8 @@ fun SessionScreen(
|
|||||||
settings = settings,
|
settings = settings,
|
||||||
sessionId = summary.id,
|
sessionId = summary.id,
|
||||||
title = title,
|
title = title,
|
||||||
|
effort = summary.effort.takeIf { summary.takesEffort },
|
||||||
|
takesEffort = summary.takesEffort,
|
||||||
cachedBytes = cachedBytes,
|
cachedBytes = cachedBytes,
|
||||||
// The purge finishes before the epoch moves, because the relaunched opening effect
|
// The purge finishes before the epoch moves, because the relaunched opening effect
|
||||||
// reads the same directory and would otherwise draw what is about to be deleted. The
|
// reads the same directory and would otherwise draw what is about to be deleted. The
|
||||||
@@ -1869,6 +2003,7 @@ fun SessionScreen(
|
|||||||
},
|
},
|
||||||
onDismiss = { settingsOpen = false },
|
onDismiss = { settingsOpen = false },
|
||||||
onCopyRenderReport = copyRenderReport,
|
onCopyRenderReport = copyRenderReport,
|
||||||
|
onRunBenchmark = runBenchmark,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2122,6 +2257,13 @@ private fun SessionStatusRow(
|
|||||||
/** Context the session is holding, or null where nothing has measured it. */
|
/** Context the session is holding, or null where nothing has measured it. */
|
||||||
contextTokens: Long?,
|
contextTokens: Long?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
/**
|
||||||
|
* Whether this row is for a subagent rather than a session, which changes only one word:
|
||||||
|
* "exited" reads as "finished" there too, the same as the subagent list's own card -- a
|
||||||
|
* subagent's process was always its parent's, so "exited" would read as a fault rather than the
|
||||||
|
* ordinary way one of these ends.
|
||||||
|
*/
|
||||||
|
subagent: Boolean = false,
|
||||||
) {
|
) {
|
||||||
DebugStats.count("status row recomposed")
|
DebugStats.count("status row recomposed")
|
||||||
Row(
|
Row(
|
||||||
@@ -2178,7 +2320,7 @@ private fun SessionStatusRow(
|
|||||||
Text(
|
Text(
|
||||||
when (status) {
|
when (status) {
|
||||||
"idle" -> "idle"
|
"idle" -> "idle"
|
||||||
"exited" -> "exited"
|
"exited" -> if (subagent) "finished" else "exited"
|
||||||
"awaitingInput" -> "your turn"
|
"awaitingInput" -> "your turn"
|
||||||
"unknown" -> "can't tell"
|
"unknown" -> "can't tell"
|
||||||
else -> status
|
else -> status
|
||||||
@@ -2249,7 +2391,7 @@ private const val ONE_TAP_MS = 250L
|
|||||||
* session is set to without spending a second line on saying it.
|
* session is set to without spending a second line on saying it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
||||||
var open by remember { mutableStateOf(false) }
|
var open by remember { mutableStateOf(false) }
|
||||||
// When an outside touch last closed the menu.
|
// When an outside touch last closed the menu.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -26,6 +28,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -54,6 +60,16 @@ fun SessionSettingsDialog(
|
|||||||
*/
|
*/
|
||||||
title: String,
|
title: String,
|
||||||
onRenamed: (String) -> Unit,
|
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
|
* 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.
|
* the Reload row below, which is what would discard it.
|
||||||
@@ -66,9 +82,18 @@ fun SessionSettingsDialog(
|
|||||||
* measures is that screen's own state.
|
* measures is that screen's own state.
|
||||||
*/
|
*/
|
||||||
onCopyRenderReport: () -> Unit,
|
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()
|
val scope = rememberCoroutineScope()
|
||||||
var name by remember(sessionId) { mutableStateOf(title) }
|
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 saving by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
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
|
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
||||||
@@ -77,6 +102,16 @@ fun SessionSettingsDialog(
|
|||||||
// and a spinner sits beside it, which is what not knowing looks like.
|
// and a spinner sits beside it, which is what not knowing looks like.
|
||||||
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
||||||
var notifyError by remember { mutableStateOf<String?>(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
|
// 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
|
// 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
|
// same as one whose directory is unknown -- the field is only enabled once one of those is
|
||||||
@@ -90,6 +125,9 @@ fun SessionSettingsDialog(
|
|||||||
try {
|
try {
|
||||||
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
||||||
notify = fresh.notify
|
notify = fresh.notify
|
||||||
|
autoResume = fresh.autoResume
|
||||||
|
resumeMessage = fresh.autoResumeMessage
|
||||||
|
resumeAt = fresh.resumeAt
|
||||||
cwd = fresh.cwd.orEmpty()
|
cwd = fresh.cwd.orEmpty()
|
||||||
typedCwd = fresh.cwd.orEmpty()
|
typedCwd = fresh.cwd.orEmpty()
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
@@ -97,6 +135,8 @@ fun SessionSettingsDialog(
|
|||||||
// instead of offering a position nothing confirmed.
|
// instead of offering a position nothing confirmed.
|
||||||
notifyError = e.message
|
notifyError = e.message
|
||||||
notify = null
|
notify = null
|
||||||
|
resumeError = e.message
|
||||||
|
autoResume = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +165,26 @@ fun SessionSettingsDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// 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,
|
// 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.
|
// and one that stays moved after a refusal lies.
|
||||||
@@ -142,6 +202,39 @@ fun SessionSettingsDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// 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.
|
// request whose success would look exactly like the failure of having typed nothing.
|
||||||
val changed = name.trim().isNotEmpty() && name.trim() != title
|
val changed = name.trim().isNotEmpty() && name.trim() != title
|
||||||
@@ -168,7 +261,10 @@ fun SessionSettingsDialog(
|
|||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Session settings") },
|
title = { Text("Session settings") },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
// 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(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
@@ -212,6 +308,70 @@ fun SessionSettingsDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -257,6 +417,44 @@ fun SessionSettingsDialog(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -317,6 +515,21 @@ fun SessionSettingsDialog(
|
|||||||
Text("Render timings", modifier = Modifier.weight(1f))
|
Text("Render timings", modifier = Modifier.weight(1f))
|
||||||
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
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
|
// Disabled rather than absent while there is nothing to save: a button that comes and goes
|
||||||
@@ -329,3 +542,21 @@ fun SessionSettingsDialog(
|
|||||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
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"
|
||||||
|
}
|
||||||
@@ -70,12 +70,21 @@ class UsageFeed(
|
|||||||
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
||||||
val refresh: () -> Unit,
|
val refresh: () -> Unit,
|
||||||
) {
|
) {
|
||||||
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */
|
/**
|
||||||
fun forSetup(setup: String): SessionUsage =
|
* What meters [session], and what that meter came back as. See [usageFor] for the states.
|
||||||
when (val state = snapshots) {
|
*
|
||||||
|
* 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.Loading -> SessionUsage.Waiting
|
||||||
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
||||||
is LoadState.Loaded -> usageFor(state.value, setup)
|
is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +167,15 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would report a
|
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
|
||||||
// problem about a setup somebody chose, on every screen, forever.
|
// a problem about a setup somebody chose, on every screen, forever.
|
||||||
if (usage is SessionUsage.NotMetered) {
|
//
|
||||||
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,9 +186,10 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
// Words, not a colour and not an empty bar: every one of these is a different kind of
|
// 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.
|
// answer from "this much is used", and only words carry a difference in kind.
|
||||||
when (val state = usage) {
|
when (val state = usage) {
|
||||||
SessionUsage.NotMetered -> Unit
|
// 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.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
||||||
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
|
|
||||||
is SessionUsage.Known -> {
|
is SessionUsage.Known -> {
|
||||||
val window = state.windows.firstOrNull { it.kind == "session" }
|
val window = state.windows.firstOrNull { it.kind == "session" }
|
||||||
if (window == null) {
|
if (window == null) {
|
||||||
@@ -234,16 +250,22 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One machine's snapshot, out of every machine's.
|
* 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.
|
* 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
|
* 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.
|
* machine having no quota rather than the question going unanswered.
|
||||||
*/
|
*/
|
||||||
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
|
fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
|
||||||
// No snapshot at all means the backend never asked, which it only does for a machine with
|
// No snapshot at all means the backend never asked, which it only does where there is nothing
|
||||||
// nothing metered on it. That is a different answer from having asked and failed.
|
// to ask about. That is a different answer from having asked and failed.
|
||||||
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
|
val mine =
|
||||||
|
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
|
||||||
|
?: return SessionUsage.NotMetered
|
||||||
if (mine.state != "ok") {
|
if (mine.state != "ok") {
|
||||||
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ private fun AddSetupDialog(
|
|||||||
var address by remember { mutableStateOf("") }
|
var address by remember { mutableStateOf("") }
|
||||||
var identity by remember { mutableStateOf("") }
|
var identity by remember { mutableStateOf("") }
|
||||||
var attachmentsDir by remember { mutableStateOf("") }
|
var attachmentsDir by remember { mutableStateOf("") }
|
||||||
|
var modelsDir by remember { mutableStateOf("") }
|
||||||
var tested by remember { mutableStateOf<String?>(null) }
|
var tested by remember { mutableStateOf<String?>(null) }
|
||||||
var testing by remember { mutableStateOf(false) }
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -250,6 +251,7 @@ private fun AddSetupDialog(
|
|||||||
port = typedPort,
|
port = typedPort,
|
||||||
identityFile = identity.trim().ifEmpty { null },
|
identityFile = identity.trim().ifEmpty { null },
|
||||||
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
||||||
|
modelsDir = modelsDir.trim().ifEmpty { null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +295,14 @@ private fun AddSetupDialog(
|
|||||||
label = { Text("Folder for attached files (optional)") },
|
label = { Text("Folder for attached files (optional)") },
|
||||||
singleLine = true,
|
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 {
|
tested?.let {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
|||||||
@@ -61,18 +61,31 @@ fun SpawnScreen(
|
|||||||
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
|
// "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.
|
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
|
||||||
var permissionMode by remember { mutableStateOf("auto") }
|
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) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
|
// 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.
|
// form worth keeping, and that one leaves nothing to fill in.
|
||||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
var spawnError by remember { mutableStateOf<String?>(null) }
|
||||||
// Downloaded models, for a llama provider to choose between. Kept separate from the setups: a
|
// The models on the *chosen machine*, for a llama provider to choose between. Kept separate
|
||||||
// Claude session needs none, so failing to list them must not stop the screen rendering.
|
// 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 models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
||||||
var modelKey by remember { mutableStateOf<String?>(null) }
|
var modelKey by remember { mutableStateOf<String?>(null) }
|
||||||
var contextSize by remember { mutableStateOf("") }
|
var contextSize by remember { mutableStateOf("") }
|
||||||
var temperature by remember { mutableStateOf("") }
|
var temperature by remember { mutableStateOf("") }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
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 =
|
options =
|
||||||
try {
|
try {
|
||||||
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
||||||
@@ -83,9 +96,6 @@ fun SpawnScreen(
|
|||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
}
|
}
|
||||||
models =
|
|
||||||
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
|
|
||||||
.getOrDefault(emptyList())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||||
@@ -115,6 +125,17 @@ fun SpawnScreen(
|
|||||||
is LoadState.Loaded -> state.value
|
is LoadState.Loaded -> state.value
|
||||||
}
|
}
|
||||||
val setup = setups.firstOrNull { it.name == setupName }
|
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 }
|
val current = setup?.providers?.firstOrNull { it.name == providerName }
|
||||||
// Only the Claude CLI has models, a working directory and permission modes; keying the
|
// 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
|
// extra fields on the kind rather than the provider name keeps a second Claude provider
|
||||||
@@ -175,12 +196,13 @@ fun SpawnScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (isLlama) {
|
if (isLlama) {
|
||||||
// A llama session names one of the models this backend has downloaded, so the choice is
|
// A llama session names one of the models on the machine it will run on, so the
|
||||||
// that list rather than free text -- a name that is not on disk is a session that
|
// choice is that list rather than free text -- a name that is not on that machine's
|
||||||
// cannot start.
|
// disk is a session that cannot start.
|
||||||
if (models.isEmpty()) {
|
if (models.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"No models downloaded yet. Get one from the Models screen first.",
|
"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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -251,6 +273,19 @@ fun SpawnScreen(
|
|||||||
selected = permissionMode,
|
selected = permissionMode,
|
||||||
onSelect = { permissionMode = it },
|
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))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
@@ -268,6 +303,13 @@ fun SpawnScreen(
|
|||||||
try {
|
try {
|
||||||
val spawned =
|
val spawned =
|
||||||
withContext(Dispatchers.IO) {
|
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(
|
spawnSession(
|
||||||
settings,
|
settings,
|
||||||
// The id, not the label: labels are editable and the server
|
// The id, not the label: labels are editable and the server
|
||||||
@@ -280,6 +322,7 @@ fun SpawnScreen(
|
|||||||
if (isLlama) modelKey else model.trim().takeIf { isClaude },
|
if (isLlama) modelKey else model.trim().takeIf { isClaude },
|
||||||
cwd = cwd.trim().takeIf { isClaude },
|
cwd = cwd.trim().takeIf { isClaude },
|
||||||
permissionMode = permissionMode.takeIf { isClaude },
|
permissionMode = permissionMode.takeIf { isClaude },
|
||||||
|
effort = effort.takeIf { isClaude },
|
||||||
// Sent only when set, so blank means "whatever llama.cpp does
|
// Sent only when set, so blank means "whatever llama.cpp does
|
||||||
// by default" rather than a zero.
|
// by default" rather than a zero.
|
||||||
params =
|
params =
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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 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"
|
||||||
|
}
|
||||||
@@ -35,8 +35,15 @@ class TranscriptCache(
|
|||||||
private val root: File,
|
private val root: File,
|
||||||
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
|
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
|
||||||
) {
|
) {
|
||||||
/** The cache for one session, whether or not anything has been stored for it yet. */
|
/**
|
||||||
fun session(id: String): SessionCache = SessionCache(File(root, id), warn)
|
* 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
|
* Deletes every session directory not in [ids], called after a successful list fetch. The path
|
||||||
|
|||||||
@@ -175,6 +175,18 @@ sealed class TranscriptItem {
|
|||||||
val preTokens: Long?,
|
val preTokens: Long?,
|
||||||
val postTokens: Long?,
|
val postTokens: Long?,
|
||||||
) : TranscriptItem()
|
) : 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,6 +470,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
} else {
|
} else {
|
||||||
items + TranscriptItem.ImageItem(entry.seq, event.ref)
|
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.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
|
||||||
is SessionEvent.Compacted ->
|
is SessionEvent.Compacted ->
|
||||||
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicReference
|
|||||||
*/
|
*/
|
||||||
class TranscriptSource(
|
class TranscriptSource(
|
||||||
private val settings: ServerSettings,
|
private val settings: ServerSettings,
|
||||||
private val sessionId: String,
|
private val address: TranscriptAddress,
|
||||||
val cache: SessionCache,
|
val cache: SessionCache,
|
||||||
) {
|
) {
|
||||||
private val stream = AtomicReference<EventStream?>(null)
|
private val stream = AtomicReference<EventStream?>(null)
|
||||||
@@ -65,7 +65,7 @@ class TranscriptSource(
|
|||||||
val tail = cache.tail() ?: return false
|
val tail = cache.tail() ?: return false
|
||||||
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
|
// `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.
|
// the cursor when the server still has one there.
|
||||||
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1)
|
val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
|
||||||
val matches =
|
val matches =
|
||||||
answer.size == 1 &&
|
answer.size == 1 &&
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +83,7 @@ class TranscriptSource(
|
|||||||
*/
|
*/
|
||||||
suspend fun fetchOpening(): List<SeqEvent> {
|
suspend fun fetchOpening(): List<SeqEvent> {
|
||||||
DebugStats.count("transcript page from server")
|
DebugStats.count("transcript page from server")
|
||||||
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW)
|
val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
|
||||||
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
|
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
|
||||||
cache.flush()
|
cache.flush()
|
||||||
return page.map { it.second }
|
return page.map { it.second }
|
||||||
@@ -108,7 +108,7 @@ class TranscriptSource(
|
|||||||
val page =
|
val page =
|
||||||
fetchTranscript(
|
fetchTranscript(
|
||||||
settings,
|
settings,
|
||||||
sessionId,
|
address,
|
||||||
before = before,
|
before = before,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
coalesce = coalesce,
|
coalesce = coalesce,
|
||||||
@@ -131,7 +131,7 @@ class TranscriptSource(
|
|||||||
* well lose.
|
* well lose.
|
||||||
*/
|
*/
|
||||||
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
||||||
val opened = EventStream(settings, sessionId)
|
val opened = EventStream(settings, address)
|
||||||
stream.getAndSet(opened)?.close()
|
stream.getAndSet(opened)?.close()
|
||||||
try {
|
try {
|
||||||
opened.run(after, onOpen, onReset) { raw, entry ->
|
opened.run(after, onOpen, onReset) { raw, entry ->
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Overridden by the `bench` build type's resValue (build.gradle.kts) to "AI Sessions bench",
|
||||||
|
so the two are never mistaken for each other in the launcher or in Settings. -->
|
||||||
|
<string name="app_name">AI Sessions</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the transcript says where a session ran out of quota.
|
||||||
|
*
|
||||||
|
* The pair worth a test is the one that reads the same when it goes wrong: a reset time that
|
||||||
|
* arrived and one that never did. The second must not turn into a plausible-looking time, because a
|
||||||
|
* reader has no way of telling an invented one from a reported one.
|
||||||
|
*/
|
||||||
|
class LimitRowTest {
|
||||||
|
private val utc = ZoneId.of("UTC")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reported reset time is shown as a time`() {
|
||||||
|
// 2026-09-05T12:00:00Z. Asserted as a prefix and the clock reading rather than as the
|
||||||
|
// whole string: the platform's own short-time format is what this asks for, and it
|
||||||
|
// differs by JDK and locale down to which space character separates the meridiem.
|
||||||
|
val summary = limitSummary(1_788_609_600.0, utc)
|
||||||
|
assertTrue(summary.startsWith("Usage limit reached • resets "), summary)
|
||||||
|
assertTrue(summary.contains("12:00"), summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a limit with no reset time says only what is known`() {
|
||||||
|
assertEquals("Usage limit reached", limitSummary(null, utc))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ class TranscriptCacheTest {
|
|||||||
|
|
||||||
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
|
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
|
||||||
|
|
||||||
private fun session(id: String = "s") = cache().session(id)
|
private fun session(id: String = "s") = cache().session(TranscriptAddress(id))
|
||||||
|
|
||||||
private fun line(seq: Long, type: String = "toolStart") =
|
private fun line(seq: Long, type: String = "toolStart") =
|
||||||
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
|
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# The P0 benchmark fixture
|
||||||
|
|
||||||
|
`transcript.jsonl` is a synthetic transcript in the app's own event model (the JSON lines
|
||||||
|
`GET /sessions/{id}/transcript` returns; see `Events.kt`'s `parseSeqEvent` and
|
||||||
|
`server/src/session/driver.rs`) -- never a real one. It is what both the Compose `bench` build
|
||||||
|
and iris's bench build open with no server, so the two apps draw exactly the same content and a
|
||||||
|
frame-time comparison is measuring the renderer rather than the data.
|
||||||
|
|
||||||
|
Generated by `./generate.py` (Python stdlib only, seeded -- `SEED = 20260905` -- so re-running it
|
||||||
|
reproduces the same file byte for byte). It writes into `assets/` -- a separate directory from this
|
||||||
|
script and README, because the Compose `bench` build type points its own asset source set straight
|
||||||
|
at `assets/` (`app/androidApp/build.gradle.kts`'s `sourceSets { getByName("bench") }`), and a Python
|
||||||
|
script and a markdown file have no business inside an APK:
|
||||||
|
|
||||||
|
- `transcript.jsonl` -- 3,601 events. The first 3,200 (`BACKLOG_COUNT`) are the scrolled-back
|
||||||
|
history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output,
|
||||||
|
assistant replies built from headings, bold/italic/inline code, a link, fenced code blocks that
|
||||||
|
rotate through rust/kotlin/python/sh/json/toml, a markdown table, two embedded images, and
|
||||||
|
periodic `usageDelta`/`compacted` events. The remaining 400 (`STREAM_COUNT`) are not part of the
|
||||||
|
opening window -- both bench harnesses replay them at a fixed rate (20/s) through the same live
|
||||||
|
fold path a real SSE reply arrives on, which is P0's "streaming phase."
|
||||||
|
- `bench1.png`, `bench2.png` -- tiny (8x8) flat-colour PNGs, base64-free on disk but served the
|
||||||
|
same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two
|
||||||
|
`"type":"image"` events in the transcript.
|
||||||
|
|
||||||
|
Regenerate after changing the shape (a new event type, a different backlog/stream split) with
|
||||||
|
`./generate.py`, and commit the result -- it is checked in rather than generated at build time so
|
||||||
|
both apps' bench builds embed the identical bytes without needing this script at build time.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 74 B |
Binary file not shown.
|
After Width: | Height: | Size: 74 B |
File diff suppressed because it is too large.
Load diff
Executable
+186
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generates transcript.jsonl -- the synthetic fixture P0's benchmark opens in both apps.
|
||||||
|
|
||||||
|
Deterministic (fixed seed), so a Compose bench APK and an iris bench APK draw byte-identical
|
||||||
|
content: the point of the fixture is a like-for-like comparison, not a realistic one.
|
||||||
|
|
||||||
|
Never a real transcript -- see AGENTS.md's ui-sandbox.sh, which this borrows its vocabulary
|
||||||
|
style from (headings, code fences, a table, a link) rather than reusing its Claude-Code JSONL
|
||||||
|
shape. This file's shape is the *app's own event model* instead: one JSON object per line,
|
||||||
|
matching what GET /sessions/{id}/transcript returns and what Events.kt's parseSeqEvent reads
|
||||||
|
(server/src/session/driver.rs is the source of truth for the field names).
|
||||||
|
|
||||||
|
./generate.py writes transcript.jsonl and bench1.png/bench2.png here
|
||||||
|
|
||||||
|
BACKLOG_COUNT events (seq 1..BACKLOG_COUNT) are the scrolled-back history the benchmark opens
|
||||||
|
with. A further STREAM_COUNT events (seq BACKLOG_COUNT+1..) are not part of the opening window;
|
||||||
|
both bench harnesses replay them at a fixed rate as the "streaming reply" phase, appended through
|
||||||
|
the same live path a real SSE reply arrives on. Keeping both halves in one file means one
|
||||||
|
generator and one seed to keep in sync, rather than two fixtures that can drift apart.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SEED = 20260905
|
||||||
|
BACKLOG_COUNT = 3200
|
||||||
|
STREAM_COUNT = 400
|
||||||
|
HERE = Path(__file__).resolve().parent / "assets"
|
||||||
|
|
||||||
|
random.seed(SEED)
|
||||||
|
|
||||||
|
LANGUAGES = ["rust", "kotlin", "python", "sh", "json", "toml"]
|
||||||
|
|
||||||
|
CODE_SNIPPETS = {
|
||||||
|
"rust": '''fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||||
|
// a comment worth keeping: this is the fold the app's own screen runs
|
||||||
|
let mut out = items;
|
||||||
|
out.push(Item::new(seq));
|
||||||
|
out
|
||||||
|
}''',
|
||||||
|
"kotlin": '''fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> {
|
||||||
|
// mirrors the server's own event model, one item per line
|
||||||
|
return items + TranscriptItem.from(entry)
|
||||||
|
}''',
|
||||||
|
"python": '''def render_report(frames, cpu_ms, rss_kb):
|
||||||
|
# printed for a human to paste back, so every number carries its unit
|
||||||
|
return f"{frames} frames, {cpu_ms}ms cpu, {rss_kb}kb peak rss"''',
|
||||||
|
"sh": '''#!/bin/sh
|
||||||
|
# scripted scroll loop, the shape transcript-bench.sh drives on a phone
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
ui-trace record --do "swipe 540 700 540 1600 200"
|
||||||
|
done''',
|
||||||
|
"json": '{"seq": 1, "type": "status", "state": "running"}',
|
||||||
|
"toml": '''[package]
|
||||||
|
name = "bench-fixture"
|
||||||
|
version = "0.1.0"''',
|
||||||
|
}
|
||||||
|
|
||||||
|
HEADINGS = [
|
||||||
|
"## Plan",
|
||||||
|
"## What changed",
|
||||||
|
"## Why this approach",
|
||||||
|
"### Open questions",
|
||||||
|
"## Results",
|
||||||
|
]
|
||||||
|
|
||||||
|
WORDS = (
|
||||||
|
"session render report frame budget scroll transcript fold event cache "
|
||||||
|
"cursor probe stream backlog swipe fixture bench compose iris widget layout "
|
||||||
|
"measure place draw tool call token context window anchor"
|
||||||
|
).split()
|
||||||
|
|
||||||
|
|
||||||
|
def paragraph(n=24):
|
||||||
|
words = [random.choice(WORDS) for _ in range(n)]
|
||||||
|
words[0] = words[0].capitalize()
|
||||||
|
text = " ".join(words) + "."
|
||||||
|
# Sprinkle markdown inline spans so the syntax highlighter/markdown parser sees a real mix.
|
||||||
|
text = text.replace(" fold ", " **fold** ", 1)
|
||||||
|
text = text.replace(" cursor ", " *cursor* ", 1)
|
||||||
|
text = text.replace(" cache ", " `cache` ", 1)
|
||||||
|
if "bench" in text:
|
||||||
|
text = text.replace(
|
||||||
|
" bench ", " [bench](https://example.com/bench) ", 1
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def make_png(rgb, size=8):
|
||||||
|
"""A tiny, valid PNG -- flat colour, no external dependency."""
|
||||||
|
|
||||||
|
def chunk(tag, data):
|
||||||
|
c = tag + data
|
||||||
|
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c))
|
||||||
|
|
||||||
|
sig = b"\x89PNG\r\n\x1a\n"
|
||||||
|
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
|
||||||
|
raw = b""
|
||||||
|
for _ in range(size):
|
||||||
|
raw += b"\x00" + bytes(rgb) * size
|
||||||
|
idat = zlib.compress(raw)
|
||||||
|
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
HERE.mkdir(exist_ok=True)
|
||||||
|
lines = []
|
||||||
|
seq = 1
|
||||||
|
ts = 1_788_000_000.0
|
||||||
|
|
||||||
|
def emit(type_, **fields):
|
||||||
|
nonlocal seq, ts
|
||||||
|
obj = {"seq": seq, "ts": round(ts, 3), "type": type_}
|
||||||
|
obj.update(fields)
|
||||||
|
lines.append(json.dumps(obj, separators=(",", ":")))
|
||||||
|
seq += 1
|
||||||
|
ts += random.uniform(0.05, 2.0)
|
||||||
|
|
||||||
|
emit("status", state="running")
|
||||||
|
emit("settings", model="bench-model", permissionMode="auto")
|
||||||
|
|
||||||
|
image_refs = []
|
||||||
|
turn = 0
|
||||||
|
while seq <= BACKLOG_COUNT:
|
||||||
|
turn += 1
|
||||||
|
emit("userMessage", text=f"Turn {turn}: {paragraph(12)}", id=None, attachments=[])
|
||||||
|
|
||||||
|
# A tool call with kilobyte-scale input/output every few turns.
|
||||||
|
if turn % 3 == 0:
|
||||||
|
tool_id = f"tool-{turn}"
|
||||||
|
big_input = json.dumps({"path": f"/repo/file_{turn}.rs", "content": paragraph(400)})
|
||||||
|
emit("toolStart", id=tool_id, tool="Edit", input=big_input)
|
||||||
|
big_output = "\n".join(paragraph(60) for _ in range(20))
|
||||||
|
emit("toolUpdate", id=tool_id, output=big_output[: len(big_output) // 2])
|
||||||
|
emit("toolEnd", id=tool_id, output=big_output)
|
||||||
|
|
||||||
|
# A reply: a heading, prose, a fenced block in a rotating language, a table, then deltas.
|
||||||
|
emit("assistantText", delta=f"{random.choice(HEADINGS)}\n\n")
|
||||||
|
emit("assistantText", delta=paragraph(30) + "\n\n")
|
||||||
|
lang = LANGUAGES[turn % len(LANGUAGES)]
|
||||||
|
emit("assistantText", delta=f"```{lang}\n{CODE_SNIPPETS[lang]}\n```\n\n")
|
||||||
|
if turn % 5 == 0:
|
||||||
|
emit(
|
||||||
|
"assistantText",
|
||||||
|
delta="| column | value |\n|---|---|\n| a | " + paragraph(3) + " |\n\n",
|
||||||
|
)
|
||||||
|
# A run of small deltas -- the shape a live reply actually streams in.
|
||||||
|
for _ in range(random.randint(3, 8)):
|
||||||
|
emit("assistantText", delta=paragraph(6) + " ")
|
||||||
|
|
||||||
|
# A couple of images, base64 PNGs, the way a real transcript embeds a screenshot.
|
||||||
|
if turn in (10, 40):
|
||||||
|
ref = f"bench{len(image_refs) + 1}.png"
|
||||||
|
image_refs.append(ref)
|
||||||
|
emit("image", ref=ref, about=None)
|
||||||
|
|
||||||
|
emit("usageDelta", tokens=random.randint(200, 4000), context=random.randint(2000, 180000))
|
||||||
|
|
||||||
|
if turn % 15 == 0:
|
||||||
|
emit(
|
||||||
|
"compacted",
|
||||||
|
preTokens=180000,
|
||||||
|
postTokens=20000,
|
||||||
|
trigger="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The streaming-phase tail: one long reply, built entirely from text deltas, the shape a
|
||||||
|
# bench harness replays at a fixed events/sec through the live fold path.
|
||||||
|
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
|
||||||
|
while seq <= BACKLOG_COUNT + STREAM_COUNT:
|
||||||
|
emit("assistantText", delta=paragraph(5) + " ")
|
||||||
|
emit("status", state="idle")
|
||||||
|
|
||||||
|
(HERE / "transcript.jsonl").write_text("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
(HERE / "bench1.png").write_bytes(make_png((220, 90, 90)))
|
||||||
|
(HERE / "bench2.png").write_bytes(make_png((90, 150, 220)))
|
||||||
|
|
||||||
|
print(f"wrote {len(lines)} events ({BACKLOG_COUNT} backlog + {STREAM_COUNT} stream) to transcript.jsonl")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+9
-3
@@ -4,6 +4,11 @@
|
|||||||
# ./build-apk.sh the release build, signed (what the phone runs)
|
# ./build-apk.sh the release build, signed (what the phone runs)
|
||||||
# ./build-apk.sh debug the debug build, for reproducing something the
|
# ./build-apk.sh debug the debug build, for reproducing something the
|
||||||
# emulator scripts would build anyway
|
# emulator scripts would build anyway
|
||||||
|
# ./build-apk.sh bench P0's benchmark build (own app id, "AI Sessions
|
||||||
|
# bench" label, opens straight onto the fixture
|
||||||
|
# session -- see docs/RUST.md's P0 box and
|
||||||
|
# app/bench-fixture/README.md). Signed the same
|
||||||
|
# as release; never touches the CA it pins.
|
||||||
#
|
#
|
||||||
# Dev Updater's `.dev-updater.ron` at the checkout root spells these out as
|
# Dev Updater's `.dev-updater.ron` at the checkout root spells these out as
|
||||||
# build modes, one command line each; it passes nothing else, so the word
|
# build modes, one command line each; it passes nothing else, so the word
|
||||||
@@ -25,8 +30,9 @@ VARIANT=${1:-release}
|
|||||||
case "$VARIANT" in
|
case "$VARIANT" in
|
||||||
release) TASK=assembleRelease ;;
|
release) TASK=assembleRelease ;;
|
||||||
debug) TASK=assembleDebug ;;
|
debug) TASK=assembleDebug ;;
|
||||||
|
bench) TASK=assembleBench ;;
|
||||||
*)
|
*)
|
||||||
echo "build-apk.sh: unknown variant '$VARIANT' (release, debug)" >&2
|
echo "build-apk.sh: unknown variant '$VARIANT' (release, debug, bench)" >&2
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -81,7 +87,7 @@ fi
|
|||||||
# uninstalling it first: the signatures differ, and Android refuses to
|
# uninstalling it first: the signatures differ, and Android refuses to
|
||||||
# update across them.
|
# update across them.
|
||||||
KEYSTORE="${AI_APP_KEYSTORE:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks}"
|
KEYSTORE="${AI_APP_KEYSTORE:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks}"
|
||||||
if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then
|
if { [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; } && [ ! -f "$KEYSTORE" ]; then
|
||||||
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}"
|
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}"
|
||||||
KEYTOOL="${KEYTOOL:-keytool}"
|
KEYTOOL="${KEYTOOL:-keytool}"
|
||||||
if ! command -v "$KEYTOOL" >/dev/null 2>&1; then
|
if ! command -v "$KEYTOOL" >/dev/null 2>&1; then
|
||||||
@@ -97,7 +103,7 @@ if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then
|
|||||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
-storepass "$PASSWORD" -keypass "$PASSWORD" -dname "CN=ai-app" >/dev/null 2>&1)
|
-storepass "$PASSWORD" -keypass "$PASSWORD" -dname "CN=ai-app" >/dev/null 2>&1)
|
||||||
fi
|
fi
|
||||||
if [ "$VARIANT" = release ]; then
|
if [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; then
|
||||||
AI_APP_KEYSTORE="$KEYSTORE"
|
AI_APP_KEYSTORE="$KEYSTORE"
|
||||||
AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password")
|
AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password")
|
||||||
export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD
|
export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# RUST.md's I5 "Where iris's frame time goes" pass (2026-09-05). The same
|
||||||
|
# 24-swipe/6-cycle loop as transcript-bench.sh's, extracted for iris's own
|
||||||
|
# demo app -- transcript-bench.sh itself is Compose-specific (opens by
|
||||||
|
# session title through the Compose app's own UI) and cannot be called
|
||||||
|
# directly against dev.iris.android.demo.
|
||||||
|
#
|
||||||
|
# MUST be run from inside this checkout (not /tmp): ui-trace/adb pick which
|
||||||
|
# emulator to target from the current directory's basename (the
|
||||||
|
# per-checkout-AVD rule), and a previous pass lost two attempts to a `cd`
|
||||||
|
# into /tmp that made this resolve to a nonexistent "tmp" checkout.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
. ./android-env.sh >/dev/null 2>&1
|
||||||
|
|
||||||
|
cycles=${1:-6}
|
||||||
|
|
||||||
|
ui-trace record -d 3000 --do "tap 'Reset frame report'" -o /tmp/iris-bench-reset.txt >/dev/null
|
||||||
|
adb logcat -c
|
||||||
|
|
||||||
|
DO=""
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt "$cycles" ]; do
|
||||||
|
DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'"
|
||||||
|
DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'"
|
||||||
|
DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'"
|
||||||
|
DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
eval ui-trace record -d $((cycles * 16000 + 20000)) $DO -o /tmp/iris-bench-scroll.txt >/dev/null
|
||||||
|
|
||||||
|
ui-trace record -d 3000 --do "tap 'Frame report'" -o /tmp/iris-bench-report.txt >/dev/null
|
||||||
|
sleep 1
|
||||||
|
adb logcat -d -s iris-android-app:I | grep "iris frame report:"
|
||||||
@@ -17,6 +17,11 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
include(":androidApp")
|
include(":androidApp")
|
||||||
|
|
||||||
|
// E3 (RUST.md): the Kotlin/Java shell over android-shell's JNI bridge, a
|
||||||
|
// separate module from :androidApp so the ~13,000 lines of working Compose
|
||||||
|
// UI there are untouched. See shellApp/build.gradle.kts's module comment.
|
||||||
|
include(":shellApp")
|
||||||
|
|
||||||
// The app half of wg-app-link, resolved by path through the submodule so
|
// The app half of wg-app-link, resolved by path through the submodule so
|
||||||
// this checkout and the crate it consumes move together -- the same
|
// this checkout and the crate it consumes move together -- the same
|
||||||
// arrangement `server/` uses for the Rust half. See that repo's README.
|
// arrangement `server/` uses for the Rust half. See that repo's README.
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
plugins { alias(libs.plugins.androidApplication) }
|
||||||
|
|
||||||
|
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
|
||||||
|
// into Rust (`../../android-shell`). Deliberately its own module rather
|
||||||
|
// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines
|
||||||
|
// of working Compose UI this experiment does not touch, and the two can be
|
||||||
|
// installed side by side on the same development device (see
|
||||||
|
// `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme
|
||||||
|
// and Keystore alias are not the production app's). No Compose plugin, no
|
||||||
|
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain
|
||||||
|
// Java, and the CA constant below is generated as Java too.
|
||||||
|
//
|
||||||
|
// The CA this build pins is baked in the same way `androidApp`'s does --
|
||||||
|
// see that module's `build.gradle.kts` comment for the reasoning (the
|
||||||
|
// trust boundary follows the machine that builds, never a pasted copy).
|
||||||
|
// `PinnedCa.java`'s package must match `android-shell`'s
|
||||||
|
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
|
||||||
|
val pinnedCaPath: String =
|
||||||
|
System.getenv("AI_APP_CA")
|
||||||
|
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
|
||||||
|
"/ai-app/certs/ca.pem"
|
||||||
|
|
||||||
|
abstract class GeneratePinnedCa : DefaultTask() {
|
||||||
|
@get:Input abstract val caPath: Property<String>
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
@get:Optional
|
||||||
|
@get:PathSensitive(PathSensitivity.NONE)
|
||||||
|
abstract val caCertificate: RegularFileProperty
|
||||||
|
|
||||||
|
@get:OutputDirectory abstract val outputDir: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun generate() {
|
||||||
|
val path = caPath.get()
|
||||||
|
val ca = File(path)
|
||||||
|
if (!ca.isFile) {
|
||||||
|
throw GradleException(
|
||||||
|
"No CA certificate at $path.\n" +
|
||||||
|
"Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it " +
|
||||||
|
"generates the CA this build pins.\n" +
|
||||||
|
"Set AI_APP_CA=/path/to/ca.pem to build against a different one."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val pem = ca.readText().trim()
|
||||||
|
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
|
||||||
|
throw GradleException("$path is not a PEM certificate.")
|
||||||
|
}
|
||||||
|
val dir = outputDir.get().dir("com/example/aiapp/shell").asFile
|
||||||
|
dir.mkdirs()
|
||||||
|
// Same reasoning as androidApp's generatePinnedCert: the text block
|
||||||
|
// must start immediately after the opening `"""`, or
|
||||||
|
// CertificateFactory stops recognising the "-----BEGIN" preamble.
|
||||||
|
File(dir, "PinnedCa.java")
|
||||||
|
.writeText(
|
||||||
|
"""
|
||||||
|
|// Generated from $path by the generatePinnedCa task. Do not edit.
|
||||||
|
|package com.example.aiapp.shell;
|
||||||
|
|
|
||||||
|
|public final class PinnedCa {
|
||||||
|
| private PinnedCa() {}
|
||||||
|
| public static final String PINNED_CA_PEM = ""${'"'}
|
||||||
|
|$pem""${'"'};
|
||||||
|
|}
|
||||||
|
|"""
|
||||||
|
.trimMargin()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val generatePinnedCa =
|
||||||
|
tasks.register<GeneratePinnedCa>("generatePinnedCa") {
|
||||||
|
val ca = file(pinnedCaPath)
|
||||||
|
caPath.set(pinnedCaPath)
|
||||||
|
if (ca.isFile) {
|
||||||
|
caCertificate.set(ca)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.aiapp.shell"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.example.aiapp.shell"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 37
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
// Same reasoning and same key as androidApp's (see that module's comment): E5 (RUST.md)
|
||||||
|
// signs its own, Gradle-free build with this same keystore, and the two can only
|
||||||
|
// `adb install -r` over each other if they carry the same certificate.
|
||||||
|
val keystore = System.getenv("AI_APP_KEYSTORE")
|
||||||
|
signingConfigs {
|
||||||
|
if (keystore != null) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keystore)
|
||||||
|
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
|
||||||
|
keyAlias = "ai-app"
|
||||||
|
keyPassword = storePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// E5 (RUST.md): the xtask dexes and packages this module's Java sources itself, but it does
|
||||||
|
// not resolve Maven dependencies -- reimplementing a dependency resolver was out of scope for a
|
||||||
|
// packaging step, so this one task is the single place Gradle still runs in that pipeline. It
|
||||||
|
// asks the dependency graph for the *post-transform* jars (AARs already unpacked to a classes
|
||||||
|
// jar, the same artifact type AGP's own dexing task consumes) rather than the raw configuration,
|
||||||
|
// which would hand back .aar files d8 cannot read directly.
|
||||||
|
val artifactType = Attribute.of("artifactType", String::class.java)
|
||||||
|
|
||||||
|
tasks.register("printRuntimeClasspathJars") {
|
||||||
|
description = "Writes the resolved release runtime classpath jars, one per line, for xtask."
|
||||||
|
val outputFile = layout.buildDirectory.file("xtask/runtime-classpath.txt")
|
||||||
|
outputs.file(outputFile)
|
||||||
|
val jars =
|
||||||
|
configurations
|
||||||
|
.getByName("releaseRuntimeClasspath")
|
||||||
|
.incoming
|
||||||
|
.artifactView { attributes.attribute(artifactType, "android-classes-jar") }
|
||||||
|
.files
|
||||||
|
// Captured as a plain FileCollection (not the ArtifactView itself, which the
|
||||||
|
// configuration cache cannot serialize) so this task is still cacheable.
|
||||||
|
inputs.files(jars)
|
||||||
|
doLast {
|
||||||
|
val file = outputFile.get().asFile
|
||||||
|
file.parentFile.mkdirs()
|
||||||
|
file.writeText(jars.joinToString("\n") { it.absolutePath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants { variant ->
|
||||||
|
variant.sources.java?.addGeneratedSourceDirectory(generatePinnedCa, GeneratePinnedCa::outputDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// The Keystore-sealed enrollment (ServerStore/ServerSettings) --
|
||||||
|
// android-shell's settings.rs calls into this Kotlin class directly
|
||||||
|
// over JNI rather than re-sealing the token in Rust; see that file's
|
||||||
|
// module doc.
|
||||||
|
implementation(project(":link"))
|
||||||
|
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
|
||||||
|
// ServiceCompat -- android-shell's notify.rs calls these classes over
|
||||||
|
// JNI so the pre-26 fallback behaviour (no channels) lives once, in
|
||||||
|
// the library that already has it, rather than being re-derived as a
|
||||||
|
// set of Build.VERSION.SDK_INT branches in Rust.
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Mirrors androidApp's manifest (AGENTS.md: reuse it rather than
|
||||||
|
re-deriving it) for the permissions and declarations E3 actually
|
||||||
|
exercises. Not carried over: the QR scanner activity (this
|
||||||
|
experiment enrolls via the aiappshell://enroll deep link directly,
|
||||||
|
per AGENTS.md's ui-sandbox.sh banner) and the app icon warning
|
||||||
|
suppression below, for the same reason androidApp's is there. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="AI Sessions (shell)"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||||
|
tools:ignore="MissingApplicationIcon">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- Enrollment: aiappshell://enroll?host=...&port=...&token=...,
|
||||||
|
per AGENTS.md's ui-sandbox.sh banner (fed to this app with
|
||||||
|
`adb shell am start -a android.intent.action.VIEW -d
|
||||||
|
'aiappshell://enroll?...'`, or -n'd at this component
|
||||||
|
directly if a second app also claims the aiapp scheme). -->
|
||||||
|
<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="aiappshell" android:host="enroll" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- The share sheet - see android-shell's share.rs. -->
|
||||||
|
<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, not dataSync, for the reason androidApp's manifest
|
||||||
|
gives: a connection that has to keep listening overnight
|
||||||
|
cannot accept dataSync's six-hour cap. -->
|
||||||
|
<service
|
||||||
|
android:name=".NotificationService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="E3 experiment: holds one connection to the sandbox server so a
|
||||||
|
session that needs an answer can be reported while the app is closed." />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.example.aiapp.shell;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E3's floor, per RUST.md's "How much Java is unavoidable": a class the framework
|
||||||
|
* constructs by name from the manifest, with its lifecycle methods handing straight to Rust
|
||||||
|
* (android-shell's {@code share::handle_intent}). No Compose, no layout -- there is no screen to
|
||||||
|
* draw yet (that is E4's job, on iris); {@link #toast} is this experiment's stand-in for showing
|
||||||
|
* something happened.
|
||||||
|
*/
|
||||||
|
public class MainActivity extends Activity {
|
||||||
|
static {
|
||||||
|
System.loadLibrary("android_shell");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
NotificationService.sync(this);
|
||||||
|
nativeHandleIntent(this, getIntent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// launchMode="singleTop": a notification tap or a share while this activity is already on
|
||||||
|
// top lands here rather than in a second instance -- same reasoning as MainActivity.kt's.
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
setIntent(intent);
|
||||||
|
nativeHandleIntent(this, intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from android-shell, sometimes from a background thread (a share's network call is
|
||||||
|
* never made on the calling thread -- see share.rs). {@code Toast} itself is main-thread-only,
|
||||||
|
* so this hops there with a {@link Handler} rather than assuming the caller already has.
|
||||||
|
*/
|
||||||
|
static void toast(Context context, String message) {
|
||||||
|
new Handler(Looper.getMainLooper())
|
||||||
|
.post(() -> Toast.makeText(context, message, Toast.LENGTH_LONG).show());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static native void nativeHandleIntent(Activity activity, Intent intent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.example.aiapp.shell;
|
||||||
|
|
||||||
|
import android.app.Service;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.IBinder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E3's second unavoidable Java class (RUST.md): a foreground service constructed by the framework
|
||||||
|
* from the manifest, existing only to hand its lifecycle to android-shell's {@code notify} module
|
||||||
|
* -- the SSE follow loop, deciding what a notification says, and posting it are all Rust reached
|
||||||
|
* through these three native calls. See {@code Notifications.kt}'s {@code NotificationService} for
|
||||||
|
* the Kotlin original this mirrors.
|
||||||
|
*/
|
||||||
|
public class NotificationService extends Service {
|
||||||
|
static {
|
||||||
|
System.loadLibrary("android_shell");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
return nativeOnStartCommand(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
nativeOnDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts this service if there is a server to connect to, and stops it otherwise. */
|
||||||
|
static void sync(Context context) {
|
||||||
|
nativeSync(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static native void nativeSync(Context context);
|
||||||
|
|
||||||
|
private static native int nativeOnStartCommand(Service service);
|
||||||
|
|
||||||
|
private static native void nativeOnDestroy();
|
||||||
|
}
|
||||||
Generated
+940
@@ -0,0 +1,940 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "client-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"event-model",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"ureq",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie"
|
||||||
|
version = "0.18.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
"time",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie_store"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||||
|
dependencies = [
|
||||||
|
"cookie",
|
||||||
|
"document-features",
|
||||||
|
"idna",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "displaydoc"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-model"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
"zlib-rs",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "form_urlencoded"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_collections"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"potential_utf",
|
||||||
|
"utf8_iter",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locale_core"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"litemap",
|
||||||
|
"tinystr",
|
||||||
|
"writeable",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||||
|
dependencies = [
|
||||||
|
"icu_collections",
|
||||||
|
"icu_normalizer_data",
|
||||||
|
"icu_properties",
|
||||||
|
"icu_provider",
|
||||||
|
"smallvec",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer_data"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_collections",
|
||||||
|
"icu_locale_core",
|
||||||
|
"icu_properties_data",
|
||||||
|
"icu_provider",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties_data"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_provider"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_locale_core",
|
||||||
|
"writeable",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||||
|
dependencies = [
|
||||||
|
"idna_adapter",
|
||||||
|
"smallvec",
|
||||||
|
"utf8_iter",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna_adapter"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||||
|
dependencies = [
|
||||||
|
"icu_normalizer",
|
||||||
|
"icu_properties",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litemap"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "potential_utf"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||||
|
dependencies = [
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.43"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stable_deref_trait"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "synstructure"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.55"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinystr"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "3.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"cookie_store",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"ureq-proto",
|
||||||
|
"utf8-zero",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq-proto"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "url"
|
||||||
|
version = "2.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"idna",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-zero"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8_iter"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "writeable"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
"yoke-derive",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke-derive"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||||
|
dependencies = [
|
||||||
|
"zerofrom-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom-derive"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerotrie"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec"
|
||||||
|
version = "0.11.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||||
|
dependencies = [
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec-derive"
|
||||||
|
version = "0.11.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "client-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
# The app's pure logic, held once instead of twice: the event model (shared
|
||||||
|
# with `server/` via `event-model`), the REST + SSE clients for its HTTP
|
||||||
|
# surface (see `server/src/routes.rs`'s module doc for the table), the
|
||||||
|
# transcript fold and cache, the markdown block model, the syntax
|
||||||
|
# highlighter and the ANSI parser. See `docs/CLIENT_CORE.md` for
|
||||||
|
# what this holds today, what it does not yet, and how it corresponds to
|
||||||
|
# the Kotlin it replaces.
|
||||||
|
#
|
||||||
|
# No UI framework dependency of any kind -- this crate is meant to outlive
|
||||||
|
# whichever one the app ends up drawing with (see RUST.md).
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
event-model = { path = "../event-model" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||||
|
# The blocking HTTP client for the REST calls and the long-lived SSE GETs.
|
||||||
|
# `server/` already depends on ureq for its own outbound HTTPS (the usage
|
||||||
|
# poll in usage.rs) and it is rustls-backed like the rest of this project's
|
||||||
|
# TLS, so this reuses that choice rather than pulling in reqwest's async
|
||||||
|
# stack -- a client that runs one blocking request at a time, the way
|
||||||
|
# Api.kt's `HttpURLConnection` calls and Sse.kt's blocking read loop do, has
|
||||||
|
# no need of an async runtime, and RUST.md's brief for this port is
|
||||||
|
# "lightweight" throughout.
|
||||||
|
ureq = { version = "3", features = ["json"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
//! What a tool printed, with its terminal styling applied and everything
|
||||||
|
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
|
||||||
|
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
|
||||||
|
//! here, so a [`StyledText`] of plain text plus non-overlapping
|
||||||
|
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
|
||||||
|
//! [`Style`] onto whatever it draws with.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
||||||
|
/// because the one place that needs partial transparency (dimming) says so
|
||||||
|
/// with a separate flag rather than baking it into the colour.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Rgb {
|
||||||
|
pub r: u8,
|
||||||
|
pub g: u8,
|
||||||
|
pub b: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rgb {
|
||||||
|
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||||
|
Self { r, g, b }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AnsiPalette {
|
||||||
|
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
|
||||||
|
pub colours: [Rgb; 16],
|
||||||
|
/// What uncoloured text is, needed only where a style has to state a colour.
|
||||||
|
pub foreground: Rgb,
|
||||||
|
/// What the text sits on, needed for reverse video.
|
||||||
|
pub background: Rgb,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One span's worth of styling. `None` fields mean "unspecified", the same
|
||||||
|
/// meaning `Color.Unspecified` and a null `FontWeight` carried in the Kotlin.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||||
|
pub struct Style {
|
||||||
|
pub color: Option<Rgb>,
|
||||||
|
/// How much of `color`'s alpha survives, 0.0-1.0; `None` is opaque.
|
||||||
|
pub alpha: Option<f32>,
|
||||||
|
pub background: Option<Rgb>,
|
||||||
|
pub bold: bool,
|
||||||
|
pub italic: bool,
|
||||||
|
pub underline: bool,
|
||||||
|
pub strikethrough: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain text plus the non-overlapping, ordered spans that style parts of it
|
||||||
|
/// -- this crate's stand-in for Compose's `AnnotatedString`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub struct StyledText {
|
||||||
|
pub text: String,
|
||||||
|
pub spans: Vec<(Range<usize>, Style)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StyledText {
|
||||||
|
fn plain(text: String) -> Self {
|
||||||
|
Self {
|
||||||
|
text,
|
||||||
|
spans: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ESC: char = '\u{1B}';
|
||||||
|
const BELL: char = '\u{7}';
|
||||||
|
|
||||||
|
/// [text] with its terminal styling applied and everything else taken out;
|
||||||
|
/// see the module doc.
|
||||||
|
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||||
|
// The common case by a long way -- nothing to do, and nothing allocated
|
||||||
|
// to find that out.
|
||||||
|
if !text.contains(ESC) && !text.contains('\r') {
|
||||||
|
return StyledText::plain(text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut runs: Vec<(String, Option<Style>)> = Vec::new();
|
||||||
|
let mut sgr = Sgr::PLAIN;
|
||||||
|
let mut at = 0usize;
|
||||||
|
let mut plain = String::new();
|
||||||
|
|
||||||
|
let flush = |plain: &mut String, sgr: Sgr, runs: &mut Vec<(String, Option<Style>)>| {
|
||||||
|
if !plain.is_empty() {
|
||||||
|
runs.push((std::mem::take(plain), sgr.span(palette)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while at < chars.len() {
|
||||||
|
let c = chars[at];
|
||||||
|
if c == ESC {
|
||||||
|
flush(&mut plain, sgr, &mut runs);
|
||||||
|
at = skip_escape(&chars, at, |params, final_byte| {
|
||||||
|
if final_byte == 'm' {
|
||||||
|
sgr = sgr.apply(params, palette);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
|
||||||
|
// 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.
|
||||||
|
flush(&mut plain, sgr, &mut runs);
|
||||||
|
drop_line(&mut runs);
|
||||||
|
at += 1;
|
||||||
|
} else if c == '\r' {
|
||||||
|
at += 1;
|
||||||
|
} else if c >= ' ' || c == '\n' || c == '\t' {
|
||||||
|
// 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.
|
||||||
|
plain.push(c);
|
||||||
|
at += 1;
|
||||||
|
} else {
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(&mut plain, sgr, &mut runs);
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
for (run_text, style) in runs {
|
||||||
|
let start = out.len();
|
||||||
|
out.push_str(&run_text);
|
||||||
|
if let Some(style) = style {
|
||||||
|
spans.push((start..out.len(), style));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StyledText { text: out, spans }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throws away everything written since the last line break, as a carriage
|
||||||
|
/// return does.
|
||||||
|
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
||||||
|
while let Some((text, style)) = runs.pop() {
|
||||||
|
if let Some(break_at) = text.rfind('\n') {
|
||||||
|
runs.push((text[..=break_at].to_string(), style));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bytes that end a CSI sequence.
|
||||||
|
fn is_csi_final(c: char) -> bool {
|
||||||
|
('@'..='~').contains(&c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) -> usize {
|
||||||
|
let Some(&next) = chars.get(at + 1) else {
|
||||||
|
return at + 1;
|
||||||
|
};
|
||||||
|
match next {
|
||||||
|
'[' => {
|
||||||
|
let mut end = at + 2;
|
||||||
|
while end < chars.len() && !is_csi_final(chars[end]) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
if end >= chars.len() {
|
||||||
|
// 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.
|
||||||
|
chars.len()
|
||||||
|
} else {
|
||||||
|
let params: String = chars[at + 2..end].iter().collect();
|
||||||
|
on_csi(¶ms, chars[end]);
|
||||||
|
end + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
']' | 'P' | 'X' | '^' | '_' => {
|
||||||
|
// Runs to a string terminator: `ESC \`, or the bell that xterm
|
||||||
|
// allows after an OSC.
|
||||||
|
let mut end = at + 2;
|
||||||
|
while end < chars.len() {
|
||||||
|
if chars[end] == BELL {
|
||||||
|
return end + 1;
|
||||||
|
}
|
||||||
|
if chars[end] == ESC && chars.get(end + 1) == Some(&'\\') {
|
||||||
|
return end + 2;
|
||||||
|
}
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
chars.len()
|
||||||
|
}
|
||||||
|
_ => at + 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything an SGR sequence can turn on, as the terminal tracks it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
struct Sgr {
|
||||||
|
fg: Option<Rgb>,
|
||||||
|
bg: Option<Rgb>,
|
||||||
|
bold: bool,
|
||||||
|
dim: bool,
|
||||||
|
italic: bool,
|
||||||
|
underline: bool,
|
||||||
|
strike: bool,
|
||||||
|
reverse: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of its colour dim text keeps: enough to read, little enough to recede.
|
||||||
|
const DIM_ALPHA: f32 = 0.65;
|
||||||
|
|
||||||
|
impl Sgr {
|
||||||
|
const PLAIN: Sgr = Sgr {
|
||||||
|
fg: None,
|
||||||
|
bg: None,
|
||||||
|
bold: false,
|
||||||
|
dim: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
strike: false,
|
||||||
|
reverse: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `None` while nothing is set, so unstyled output costs no spans at all.
|
||||||
|
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
||||||
|
if *self == Sgr::PLAIN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let front = if self.reverse {
|
||||||
|
Some(self.bg.unwrap_or(palette.background))
|
||||||
|
} else {
|
||||||
|
self.fg
|
||||||
|
};
|
||||||
|
let back = if self.reverse {
|
||||||
|
Some(self.fg.unwrap_or(palette.foreground))
|
||||||
|
} else {
|
||||||
|
self.bg
|
||||||
|
};
|
||||||
|
// Dim has to have a colour to dim, so where none was named it dims
|
||||||
|
// the ordinary one.
|
||||||
|
let stated = front.or(if self.dim {
|
||||||
|
Some(palette.foreground)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
});
|
||||||
|
Some(Style {
|
||||||
|
color: stated,
|
||||||
|
alpha: if self.dim { Some(DIM_ALPHA) } else { None },
|
||||||
|
background: back,
|
||||||
|
bold: self.bold,
|
||||||
|
italic: self.italic,
|
||||||
|
underline: self.underline,
|
||||||
|
strikethrough: self.strike,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
|
||||||
|
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
|
||||||
|
// zero too.
|
||||||
|
let codes: Vec<i64> = params
|
||||||
|
.split(';')
|
||||||
|
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
|
||||||
|
.collect();
|
||||||
|
let mut state = *self;
|
||||||
|
let mut at = 0usize;
|
||||||
|
while at < codes.len() {
|
||||||
|
let code = codes[at];
|
||||||
|
state = match code {
|
||||||
|
0 => Sgr::PLAIN,
|
||||||
|
1 => Sgr {
|
||||||
|
bold: true,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
2 => Sgr { dim: true, ..state },
|
||||||
|
3 => Sgr {
|
||||||
|
italic: true,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
4 => Sgr {
|
||||||
|
underline: true,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
7 => Sgr {
|
||||||
|
reverse: true,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
9 => Sgr {
|
||||||
|
strike: true,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
21 | 22 => Sgr {
|
||||||
|
bold: false,
|
||||||
|
dim: false,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
23 => Sgr {
|
||||||
|
italic: false,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
24 => Sgr {
|
||||||
|
underline: false,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
27 => Sgr {
|
||||||
|
reverse: false,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
29 => Sgr {
|
||||||
|
strike: false,
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
30..=37 => Sgr {
|
||||||
|
fg: Some(palette.colours[(code - 30) as usize]),
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
90..=97 => Sgr {
|
||||||
|
fg: Some(palette.colours[(code - 90 + 8) as usize]),
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
40..=47 => Sgr {
|
||||||
|
bg: Some(palette.colours[(code - 40) as usize]),
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
100..=107 => Sgr {
|
||||||
|
bg: Some(palette.colours[(code - 100 + 8) as usize]),
|
||||||
|
..state
|
||||||
|
},
|
||||||
|
39 => Sgr { fg: None, ..state },
|
||||||
|
49 => Sgr { bg: None, ..state },
|
||||||
|
38 | 48 => {
|
||||||
|
let (colour, last) = extended_colour(&codes, at, palette);
|
||||||
|
at = last;
|
||||||
|
if code == 38 {
|
||||||
|
Sgr {
|
||||||
|
fg: colour,
|
||||||
|
..state
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Sgr {
|
||||||
|
bg: colour,
|
||||||
|
..state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => state,
|
||||||
|
};
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
|
||||||
|
match codes.get(at + 1) {
|
||||||
|
Some(&5) => match codes.get(at + 2) {
|
||||||
|
None => (None, at + 1),
|
||||||
|
Some(&n) => (Some(indexed_colour(n, palette)), at + 2),
|
||||||
|
},
|
||||||
|
Some(&2) => {
|
||||||
|
let r = codes.get(at + 2);
|
||||||
|
let g = codes.get(at + 3);
|
||||||
|
let b = codes.get(at + 4);
|
||||||
|
match (r, g, b) {
|
||||||
|
(Some(&r), Some(&g), Some(&b)) => (
|
||||||
|
Some(Rgb::new(
|
||||||
|
r.clamp(0, 255) as u8,
|
||||||
|
g.clamp(0, 255) as u8,
|
||||||
|
b.clamp(0, 255) as u8,
|
||||||
|
)),
|
||||||
|
at + 4,
|
||||||
|
),
|
||||||
|
_ => (None, at + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (None, at + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
|
||||||
|
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||||
|
|
||||||
|
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
|
||||||
|
/// grey ramp.
|
||||||
|
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
||||||
|
if n < 0 {
|
||||||
|
palette.foreground
|
||||||
|
} else if n < 16 {
|
||||||
|
palette.colours[n as usize]
|
||||||
|
} else if n < 232 {
|
||||||
|
let i = (n - 16) as usize;
|
||||||
|
Rgb::new(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
|
||||||
|
} else if n < 256 {
|
||||||
|
let grey = (8 + (n - 232) * 10) as u8;
|
||||||
|
Rgb::new(grey, grey, grey)
|
||||||
|
} else {
|
||||||
|
palette.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
|
||||||
|
/// white foreground, black background.
|
||||||
|
fn palette() -> AnsiPalette {
|
||||||
|
let mut colours = [Rgb::new(0, 0, 0); 16];
|
||||||
|
for (i, c) in colours.iter_mut().enumerate() {
|
||||||
|
*c = Rgb::new(i as u8, 0, 0);
|
||||||
|
}
|
||||||
|
AnsiPalette {
|
||||||
|
colours,
|
||||||
|
foreground: Rgb::new(255, 255, 255),
|
||||||
|
background: Rgb::new(0, 0, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn styled(text: &str) -> StyledText {
|
||||||
|
ansi_styled(text, &palette())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The style covering the first character of `word`, or `None` where
|
||||||
|
/// nothing styles it.
|
||||||
|
fn style_over(text: &str, word: &str) -> Option<Style> {
|
||||||
|
let out = styled(text);
|
||||||
|
let at = out
|
||||||
|
.text
|
||||||
|
.find(word)
|
||||||
|
.unwrap_or_else(|| panic!("no {word:?} in {}", out.text));
|
||||||
|
out.spans
|
||||||
|
.iter()
|
||||||
|
.find(|(range, _)| range.contains(&at))
|
||||||
|
.map(|(_, style)| *style)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_colour_becomes_a_span_and_the_sequence_itself_disappears() {
|
||||||
|
let text = format!("plain {ESC}[31mred{ESC}[0m plain");
|
||||||
|
assert_eq!(styled(&text).text, "plain red plain");
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&text, "red").unwrap().color,
|
||||||
|
Some(Rgb::new(1, 0, 0))
|
||||||
|
);
|
||||||
|
assert!(style_over(&text, "plain").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bright_background_and_256_colour_forms_all_reach_the_same_table() {
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[91mx"), "x").unwrap().color,
|
||||||
|
Some(Rgb::new(9, 0, 0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[44mx"), "x").unwrap().background,
|
||||||
|
Some(Rgb::new(4, 0, 0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[38;5;1mx"), "x").unwrap().color,
|
||||||
|
Some(Rgb::new(1, 0, 0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[38;5;16mx"), "x").unwrap().color,
|
||||||
|
Some(Rgb::new(0, 0, 0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[38;5;231mx"), "x").unwrap().color,
|
||||||
|
Some(Rgb::new(255, 255, 255))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
style_over(&format!("{ESC}[38;2;10;20;30mx"), "x")
|
||||||
|
.unwrap()
|
||||||
|
.color,
|
||||||
|
Some(Rgb::new(10, 20, 30))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
|
||||||
|
// A cursor move, an erase, an OSC window title with its bell, and a
|
||||||
|
// bare two-character escape.
|
||||||
|
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
|
||||||
|
assert_eq!(styled(&text).text, "abcde");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_carriage_return_rewrites_its_line_as_it_does_on_a_terminal() {
|
||||||
|
assert_eq!(styled("10%\r50%\rdone\n").text, "done\n");
|
||||||
|
assert_eq!(styled("kept\r\nfirst\rlast").text, "kept\nlast");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sequence_cut_off_mid_stream_takes_no_text_with_it() {
|
||||||
|
assert_eq!(styled(&format!("text {ESC}[3")).text, "text ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unstyled_text_costs_no_spans_at_all() {
|
||||||
|
assert_eq!(styled("nothing to do here").spans.len(), 0);
|
||||||
|
assert_eq!(styled(&format!("a{ESC}[2Jb")).spans.len(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
|
||||||
|
//! module doc for the table); the SSE half is [`crate::event_stream`].
|
||||||
|
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
|
||||||
|
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
|
||||||
|
//! which do not.
|
||||||
|
//!
|
||||||
|
//! Network I/O sits behind the [`Transport`] trait so the rest of this
|
||||||
|
//! crate, and anything built on it, can be tested against a fake one with
|
||||||
|
//! no server involved. [`UreqTransport`] is the only real implementation.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// A request that did not produce what it asked for, carrying the server's
|
||||||
|
/// own wording where it sent some.
|
||||||
|
///
|
||||||
|
/// `status` is the HTTP status where there was a response at all, and
|
||||||
|
/// `None` where the server was never reached -- mirroring `ApiException` in
|
||||||
|
/// `Api.kt`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApiError {
|
||||||
|
pub message: String,
|
||||||
|
pub status: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ApiError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::error::Error for ApiError {}
|
||||||
|
|
||||||
|
/// A request body to send, in whichever of the two shapes the surface
|
||||||
|
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
|
||||||
|
pub enum Body {
|
||||||
|
Json(Value),
|
||||||
|
Bytes {
|
||||||
|
content_type: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a transport hands back for a REST call: the status and the body
|
||||||
|
/// read whole. A streamed body ([`Transport::stream`]) is a different
|
||||||
|
/// method because its whole point is not reading it whole.
|
||||||
|
pub struct RawResponse {
|
||||||
|
pub status: u16,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The network boundary this crate's pure logic is kept out from behind.
|
||||||
|
/// `server/src/routes.rs`'s module doc is the surface this drives.
|
||||||
|
pub trait Transport: Send + Sync {
|
||||||
|
/// One request/response call -- everything but the long-lived SSE GETs.
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError>;
|
||||||
|
|
||||||
|
/// Opens `path` and answers a reader over the response body, for a
|
||||||
|
/// caller that reads it as a stream rather than all at once (the SSE
|
||||||
|
/// connections in [`crate::event_stream`]). Fails the same way
|
||||||
|
/// [`Transport::request`] does for a non-2xx response.
|
||||||
|
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One session as `GET /sessions` and `GET /sessions/{id}` report it.
|
||||||
|
/// Mirrors `Api.kt`'s `SessionSummary`; see that type's doc for what each
|
||||||
|
/// field means and why `setup` is never shown.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub setup: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keeps_own_transcript: bool,
|
||||||
|
pub setup_name: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub permission_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub imported: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub notify: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub context_tokens: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_image_edge: Option<u32>,
|
||||||
|
pub status: String,
|
||||||
|
pub last_activity: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client-core equivalent of `requestFromServer` plus the typed calls
|
||||||
|
/// built on it. Holds no state of its own beyond the transport -- the
|
||||||
|
/// session id or setup id a call is about is a parameter, per this
|
||||||
|
/// project's "ask for the least you need".
|
||||||
|
pub struct ApiClient<T: Transport> {
|
||||||
|
transport: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Transport> ApiClient<T> {
|
||||||
|
pub fn new(transport: T) -> Self {
|
||||||
|
Self { transport }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_request<R: for<'de> Deserialize<'de>>(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Value>,
|
||||||
|
) -> Result<R, ApiError> {
|
||||||
|
let raw = self.transport.request(method, path, body.map(Body::Json))?;
|
||||||
|
serde_json::from_slice(&raw.body).map_err(|e| ApiError {
|
||||||
|
message: format!("Reached the server but couldn't read its response ({e})"),
|
||||||
|
status: Some(raw.status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_request(&self, method: &str, path: &str, body: Option<Value>) -> Result<(), ApiError> {
|
||||||
|
self.transport.request(method, path, body.map(Body::Json))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_sessions(&self) -> Result<Vec<SessionSummary>, ApiError> {
|
||||||
|
self.json_request("GET", "/sessions", None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_session(&self, session_id: &str) -> Result<SessionSummary, ApiError> {
|
||||||
|
self.json_request("GET", &format!("/sessions/{session_id}"), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_message(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
text: &str,
|
||||||
|
attachment_ids: &[String],
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/message"),
|
||||||
|
Some(serde_json::json!({ "text": text, "attachmentIds": attachment_ids })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unqueue_message(&self, session_id: &str, message_id: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/unqueue"),
|
||||||
|
Some(serde_json::json!({ "messageId": message_id })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn answer_question(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
question_id: &str,
|
||||||
|
answers: &[String],
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/answer"),
|
||||||
|
Some(serde_json::json!({ "questionId": question_id, "answers": answers })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn interrupt_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request("POST", &format!("/sessions/{session_id}/interrupt"), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request("POST", &format!("/sessions/{session_id}/stop"), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request("POST", &format!("/sessions/{session_id}/start"), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename_session(&self, session_id: &str, title: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/title"),
|
||||||
|
Some(serde_json::json!({ "title": title })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_cwd(&self, session_id: &str, cwd: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/cwd"),
|
||||||
|
Some(serde_json::json!({ "cwd": cwd })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_model(&self, session_id: &str, model: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/model"),
|
||||||
|
Some(serde_json::json!({ "model": model })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_permission_mode(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
mode: &str,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/permission-mode"),
|
||||||
|
Some(serde_json::json!({ "permissionMode": mode })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_notify(&self, session_id: &str, notify: bool) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/notify"),
|
||||||
|
Some(serde_json::json!({ "notify": notify })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_command(&self, session_id: &str, text: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request(
|
||||||
|
"POST",
|
||||||
|
&format!("/sessions/{session_id}/command"),
|
||||||
|
Some(serde_json::json!({ "text": text })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compact_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||||
|
self.empty_request("POST", &format!("/sessions/{session_id}/compact"), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_session(&self, session_id: &str, delete_foreign: bool) -> Result<(), ApiError> {
|
||||||
|
let path = if delete_foreign {
|
||||||
|
format!("/sessions/{session_id}?deleteForeign=true")
|
||||||
|
} else {
|
||||||
|
format!("/sessions/{session_id}")
|
||||||
|
};
|
||||||
|
self.empty_request("DELETE", &path, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page of transcript history. `before` is the newest-first cursor
|
||||||
|
/// (server default is "the newest page" when absent, which a caller
|
||||||
|
/// gets by passing `None`); the events themselves are handed back as
|
||||||
|
/// [`event_model::SeqEvent`] via `crate::event_stream`'s parsing, kept
|
||||||
|
/// out of this method's signature so a caller that only wants the raw
|
||||||
|
/// lines (for the transcript cache) is not forced to parse them.
|
||||||
|
pub fn fetch_transcript_page(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
before: Option<u64>,
|
||||||
|
limit: u32,
|
||||||
|
coalesce: bool,
|
||||||
|
) -> Result<Vec<Value>, ApiError> {
|
||||||
|
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
||||||
|
if let Some(before) = before {
|
||||||
|
path.push_str(&format!("&before={before}"));
|
||||||
|
}
|
||||||
|
if coalesce {
|
||||||
|
path.push_str("&coalesce=true");
|
||||||
|
}
|
||||||
|
self.json_request("GET", &path, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
||||||
|
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
|
||||||
|
/// poll). Verifies the server's leaf against a single pinned CA, the way
|
||||||
|
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
|
||||||
|
/// store -- the server's certificate is self-signed on purpose (see
|
||||||
|
/// `wg-app-link`).
|
||||||
|
pub struct UreqTransport {
|
||||||
|
agent: ureq::Agent,
|
||||||
|
base_url: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UreqTransport {
|
||||||
|
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
|
||||||
|
/// exactly as read from `certs/ca.pem`.
|
||||||
|
pub fn new(
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
token: impl Into<String>,
|
||||||
|
ca_pem: &[u8],
|
||||||
|
) -> Result<Self, ApiError> {
|
||||||
|
let cert = ureq::tls::Certificate::from_pem(ca_pem).map_err(|e| ApiError {
|
||||||
|
message: format!("The pinned CA certificate could not be read: {e}"),
|
||||||
|
status: None,
|
||||||
|
})?;
|
||||||
|
let tls_config = ureq::tls::TlsConfig::builder()
|
||||||
|
.root_certs(ureq::tls::RootCerts::new_with_certs(&[cert]))
|
||||||
|
.build();
|
||||||
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||||
|
.tls_config(tls_config)
|
||||||
|
// Read the body ourselves on every status, the way
|
||||||
|
// `requestFromServer` does: the server's own error wording is
|
||||||
|
// in the body of a 4xx/5xx, and the default behaviour throws
|
||||||
|
// it away before this code can read it.
|
||||||
|
.http_status_as_error(false)
|
||||||
|
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
||||||
|
.build()
|
||||||
|
.into();
|
||||||
|
Ok(Self {
|
||||||
|
agent,
|
||||||
|
base_url: base_url.into(),
|
||||||
|
token: token.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path: &str) -> String {
|
||||||
|
format!("{}{}", self.base_url, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport for UreqTransport {
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError> {
|
||||||
|
let url = self.url(path);
|
||||||
|
let auth = format!("Bearer {}", self.token);
|
||||||
|
let mut builder = ureq::http::Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(&url)
|
||||||
|
.header("Authorization", &auth);
|
||||||
|
let response = match body {
|
||||||
|
None => builder
|
||||||
|
.body(())
|
||||||
|
.map_err(ureq::Error::from)
|
||||||
|
.and_then(|req| self.agent.run(req)),
|
||||||
|
Some(Body::Json(value)) => {
|
||||||
|
builder = builder.header("Content-Type", "application/json");
|
||||||
|
builder
|
||||||
|
.body(serde_json::to_vec(&value).unwrap_or_default())
|
||||||
|
.map_err(ureq::Error::from)
|
||||||
|
.and_then(|req| self.agent.run(req))
|
||||||
|
}
|
||||||
|
Some(Body::Bytes {
|
||||||
|
content_type,
|
||||||
|
bytes,
|
||||||
|
}) => {
|
||||||
|
builder = builder.header("Content-Type", content_type);
|
||||||
|
builder
|
||||||
|
.body(bytes)
|
||||||
|
.map_err(ureq::Error::from)
|
||||||
|
.and_then(|req| self.agent.run(req))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let mut body = Vec::new();
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.as_reader()
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.map_err(|e| ApiError {
|
||||||
|
message: format!("Reached {url} but couldn't read its response ({e})"),
|
||||||
|
status: Some(status),
|
||||||
|
})?;
|
||||||
|
if !(200..300).contains(&status) {
|
||||||
|
return Err(response_error(status, &body, path));
|
||||||
|
}
|
||||||
|
Ok(RawResponse { status, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||||
|
let url = self.url(path);
|
||||||
|
let auth = format!("Bearer {}", self.token);
|
||||||
|
let response = self
|
||||||
|
.agent
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", &auth)
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
// No read timeout: between events there is nothing to read for
|
||||||
|
// as long as the thing being followed is idle, mirroring
|
||||||
|
// `EventStream.kt`'s `readTimeout = 0`.
|
||||||
|
.config()
|
||||||
|
.timeout_recv_response(None)
|
||||||
|
.build()
|
||||||
|
.call();
|
||||||
|
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
if status != 200 {
|
||||||
|
let mut body = Vec::new();
|
||||||
|
let _ = response.body_mut().as_reader().read_to_end(&mut body);
|
||||||
|
return Err(response_error(status, &body, path));
|
||||||
|
}
|
||||||
|
Ok(Box::new(response.into_body().into_reader()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
|
||||||
|
ApiError {
|
||||||
|
message: format!(
|
||||||
|
"Couldn't reach the server at {base_url} ({e}) -- is ai-server running, and is this \
|
||||||
|
device able to reach that address (WireGuard up)? [{path}]"
|
||||||
|
),
|
||||||
|
status: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 401 wording matches `Api.kt`'s, since that message is instructions
|
||||||
|
/// for the reader rather than a diagnostic -- see this project's UI rule
|
||||||
|
/// about shortening a failure in one place rather than at each display site.
|
||||||
|
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
|
||||||
|
let detail = String::from_utf8_lossy(body).trim().to_string();
|
||||||
|
let message = if status == 401 {
|
||||||
|
"The server rejected this device's token. Re-enroll by scanning the server's QR (or \
|
||||||
|
rotate with --rotate-token and scan the new one)."
|
||||||
|
.to_string()
|
||||||
|
} else if detail.is_empty() {
|
||||||
|
format!("Server returned HTTP {status} for {path}")
|
||||||
|
} else {
|
||||||
|
detail
|
||||||
|
};
|
||||||
|
ApiError {
|
||||||
|
message,
|
||||||
|
status: Some(status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// A transport with no network at all, for the pure-logic tests this
|
||||||
|
/// module can run without a server.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeTransport {
|
||||||
|
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeTransport {
|
||||||
|
fn respond(&self, method: &str, path: &str, status: u16, body: &str) {
|
||||||
|
self.responses.lock().unwrap().push((
|
||||||
|
method.to_string(),
|
||||||
|
path.to_string(),
|
||||||
|
RawResponse {
|
||||||
|
status,
|
||||||
|
body: body.as_bytes().to_vec(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport for FakeTransport {
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
_body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError> {
|
||||||
|
let mut responses = self.responses.lock().unwrap();
|
||||||
|
let index = responses
|
||||||
|
.iter()
|
||||||
|
.position(|(m, p, _)| m == method && p == path)
|
||||||
|
.ok_or_else(|| ApiError {
|
||||||
|
message: format!("no fake response for {method} {path}"),
|
||||||
|
status: None,
|
||||||
|
})?;
|
||||||
|
let (_, _, response) = responses.remove(index);
|
||||||
|
if !(200..300).contains(&response.status) {
|
||||||
|
return Err(response_error(response.status, &response.body, path));
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, _path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||||
|
Ok(Box::new(Cursor::new(Vec::new())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_sessions_parses_the_list() {
|
||||||
|
let transport = FakeTransport::default();
|
||||||
|
transport.respond(
|
||||||
|
"GET",
|
||||||
|
"/sessions",
|
||||||
|
200,
|
||||||
|
r#"[{"id":"s1","setup":"m1","setupName":"desktop","provider":"claude_cli",
|
||||||
|
"title":"hi","status":"idle","lastActivity":1.0}]"#,
|
||||||
|
);
|
||||||
|
let client = ApiClient::new(transport);
|
||||||
|
let sessions = client.fetch_sessions().unwrap();
|
||||||
|
assert_eq!(sessions.len(), 1);
|
||||||
|
assert_eq!(sessions[0].id, "s1");
|
||||||
|
assert_eq!(sessions[0].setup_name, "desktop");
|
||||||
|
// Defaults for fields the server omits.
|
||||||
|
assert!(sessions[0].notify);
|
||||||
|
assert_eq!(sessions[0].model, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_401_gets_the_enrollment_message_regardless_of_the_bare_body() {
|
||||||
|
let transport = FakeTransport::default();
|
||||||
|
transport.respond("POST", "/sessions/s1/interrupt", 401, "unauthorized");
|
||||||
|
let client = ApiClient::new(transport);
|
||||||
|
let err = client.interrupt_session("s1").unwrap_err();
|
||||||
|
assert!(err.message.contains("Re-enroll"));
|
||||||
|
assert_eq!(err.status, Some(401));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bare_error_status_with_no_body_falls_back_to_a_generic_message() {
|
||||||
|
let transport = FakeTransport::default();
|
||||||
|
transport.respond("POST", "/sessions/s1/stop", 500, "");
|
||||||
|
let client = ApiClient::new(transport);
|
||||||
|
let err = client.stop_session("s1").unwrap_err();
|
||||||
|
assert!(err.message.contains("500"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_server_explanation_in_the_body_is_surfaced_verbatim() {
|
||||||
|
let transport = FakeTransport::default();
|
||||||
|
transport.respond(
|
||||||
|
"POST",
|
||||||
|
"/sessions/s1/cwd",
|
||||||
|
409,
|
||||||
|
"that path does not exist on this machine",
|
||||||
|
);
|
||||||
|
let client = ApiClient::new(transport);
|
||||||
|
let err = client.set_session_cwd("s1", "/nope").unwrap_err();
|
||||||
|
assert_eq!(err.message, "that path does not exist on this machine");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! What a Rust client needs to reach one enrolled server: host, port and
|
||||||
|
//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s
|
||||||
|
//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T`
|
||||||
|
//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and
|
||||||
|
//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from
|
||||||
|
//! the same text a phone would scan as a QR, with no second format
|
||||||
|
//! invented for it (RUST.md's E4).
|
||||||
|
//!
|
||||||
|
//! What this type deliberately does not decide: where it is persisted, and
|
||||||
|
//! under what file permissions. A phone seals its token in the Android
|
||||||
|
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/`
|
||||||
|
//! directory and its own file-mode conventions (MACHINE.md: owner-only,
|
||||||
|
//! never in the repo). Both are caller-specific, so they stay out of this
|
||||||
|
//! crate per the code rules' "ask for the least you need" -- see
|
||||||
|
//! `iris/desktop-app/src/config.rs` for the desktop instance.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
||||||
|
/// with `token` as a bearer header. Does not carry the pinned CA -- that is
|
||||||
|
/// a public certificate rather than a secret, and where to find it differs
|
||||||
|
/// by caller (a phone pins the one its APK was built against; a desktop
|
||||||
|
/// client is told a path).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct EnrolledServer {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnrolledServer {
|
||||||
|
/// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not
|
||||||
|
/// matter; unrecognised keys are ignored). `token` is percent-decoded,
|
||||||
|
/// since `ui-sandbox.sh` encodes it precisely because a raw token can
|
||||||
|
/// contain `+`, which turns into a space if left to a naive splitter.
|
||||||
|
pub fn parse_link(link: &str) -> Result<Self, String> {
|
||||||
|
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"'{link}' has no query string (expected \
|
||||||
|
aiapp://enroll?host=...&port=...&token=...)"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut host = None;
|
||||||
|
let mut port = None;
|
||||||
|
let mut token = None;
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let Some((key, value)) = pair.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = percent_decode(value);
|
||||||
|
match key {
|
||||||
|
"host" => host = Some(value),
|
||||||
|
"port" => port = Some(value),
|
||||||
|
"token" => token = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
|
||||||
|
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
|
||||||
|
let port: u16 = port_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
||||||
|
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
|
||||||
|
|
||||||
|
Ok(Self { host, port, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a `client_core::api::UreqTransport` reaches this server.
|
||||||
|
pub fn base_url(&self) -> String {
|
||||||
|
format!("https://{}:{}", self.host, self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%'
|
||||||
|
&& i + 2 < bytes.len()
|
||||||
|
&& let Ok(byte) =
|
||||||
|
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
|
||||||
|
{
|
||||||
|
out.push(byte);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_host_port_and_token() {
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
server,
|
||||||
|
EnrolledServer {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 8547,
|
||||||
|
token: "abcDEF123".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_order_does_not_matter() {
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(server.host, "example.com");
|
||||||
|
assert_eq!(server.port, 443);
|
||||||
|
assert_eq!(server.token, "tok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_percent_encoded_token_is_decoded() {
|
||||||
|
// ui-sandbox.sh's own reason for encoding: a raw '+' would
|
||||||
|
// otherwise arrive as a space.
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
|
||||||
|
assert_eq!(server.token, "a+b/c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_field_is_named_in_the_error() {
|
||||||
|
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("token"),
|
||||||
|
"error should name the missing field: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_numeric_port_is_named_in_the_error() {
|
||||||
|
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("port"),
|
||||||
|
"error should name the offending field: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! 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.
|
||||||
|
//! Ported from `app/.../EventStream.kt`; the framing itself is
|
||||||
|
//! [`crate::sse`].
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
|
||||||
|
use crate::api::{ApiError, Transport};
|
||||||
|
use crate::sse::SseReader;
|
||||||
|
|
||||||
|
/// The frame name the server uses to say a cursor was too far behind to
|
||||||
|
/// continue from. Must match `send_backlog` in `server/src/routes.rs`.
|
||||||
|
const RESET_EVENT: &str = "reset";
|
||||||
|
|
||||||
|
/// One frame of a session's event stream, folded from the wire shape the
|
||||||
|
/// caller needs to act on -- mirroring what `EventStream.kt`'s three
|
||||||
|
/// callbacks were for, as a single enum instead, since Rust has no
|
||||||
|
/// equivalent of handing three closures to one blocking call.
|
||||||
|
pub enum StreamItem {
|
||||||
|
/// The connection was accepted; the measured moment the stream is live
|
||||||
|
/// (see `EventStream.kt`'s doc on `onOpen` for why this, not the first
|
||||||
|
/// event, is what clears a previous failure on screen).
|
||||||
|
Open,
|
||||||
|
/// The cursor was too far behind to continue from: everything already
|
||||||
|
/// displayed is stale, and the events that follow are a fresh window.
|
||||||
|
/// Arrives before those events, so a caller that clears on it stays in
|
||||||
|
/// order.
|
||||||
|
Reset,
|
||||||
|
/// One event, as both the raw line the transcript cache stores and the
|
||||||
|
/// parsed [`SeqEvent`] the fold works from -- they have to be the same
|
||||||
|
/// line, so both travel together rather than being parsed twice from
|
||||||
|
/// two call sites.
|
||||||
|
Event { raw: String, event: SeqEvent },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows `/sessions/{id}/events?after={after}`, calling `on_item` for
|
||||||
|
/// each [`StreamItem`] until the connection drops or `on_item` asks to
|
||||||
|
/// stop (by returning `false`). Reconnecting -- with the last seq seen as
|
||||||
|
/// the new cursor -- is the caller's job, same as in the Kotlin version.
|
||||||
|
pub fn follow_session_events(
|
||||||
|
transport: &dyn Transport,
|
||||||
|
session_id: &str,
|
||||||
|
after: u64,
|
||||||
|
mut on_item: impl FnMut(StreamItem) -> bool,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let path = format!("/sessions/{session_id}/events?after={after}");
|
||||||
|
let body = transport.stream(&path)?;
|
||||||
|
if !on_item(StreamItem::Open) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut lines = BufReader::new(body).lines();
|
||||||
|
let mut reader = SseReader::new();
|
||||||
|
while let Some(line) = lines.next().transpose().map_err(|e| ApiError {
|
||||||
|
message: format!("Can't reach the server -- retrying. ({e})"),
|
||||||
|
status: None,
|
||||||
|
})? {
|
||||||
|
let Some(frame) = reader.feed_line(&line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// A named frame carries no payload and a data frame has no name.
|
||||||
|
if frame.name.as_deref() == Some(RESET_EVENT) {
|
||||||
|
if !on_item(StreamItem::Reset) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else if !frame.data.is_empty() {
|
||||||
|
let event: SeqEvent = serde_json::from_str(&frame.data).map_err(|e| ApiError {
|
||||||
|
message: format!("The server sent an event this build couldn't parse: {e}"),
|
||||||
|
status: None,
|
||||||
|
})?;
|
||||||
|
if !on_item(StreamItem::Event {
|
||||||
|
raw: frame.data,
|
||||||
|
event,
|
||||||
|
}) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::api::{Body, RawResponse};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
struct FixtureTransport {
|
||||||
|
body: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport for FixtureTransport {
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
_method: &str,
|
||||||
|
_path: &str,
|
||||||
|
_body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError> {
|
||||||
|
unimplemented!("this fixture only serves a stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
|
||||||
|
Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn events_and_a_reset_frame_are_told_apart() {
|
||||||
|
let transport = FixtureTransport {
|
||||||
|
body: "event:reset\n\ndata:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
|
||||||
|
};
|
||||||
|
let mut items = Vec::new();
|
||||||
|
follow_session_events(&transport, "s1", 0, |item| {
|
||||||
|
items.push(match item {
|
||||||
|
StreamItem::Open => "open".to_string(),
|
||||||
|
StreamItem::Reset => "reset".to_string(),
|
||||||
|
StreamItem::Event { event, .. } => format!("event:{}", event.seq),
|
||||||
|
});
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(items, vec!["open", "reset", "event:1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_caller_can_stop_early() {
|
||||||
|
let transport = FixtureTransport {
|
||||||
|
body: "data:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n\
|
||||||
|
data:{\"seq\":2,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
|
||||||
|
};
|
||||||
|
let mut count = 0;
|
||||||
|
follow_session_events(&transport, "s1", 0, |item| {
|
||||||
|
if matches!(item, StreamItem::Event { .. }) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
count < 1
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
//! A language the highlighter can colour, and the data-driven [`Rules`] each
|
||||||
|
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
|
||||||
|
//! why nearly every language is a row of data read by one shared scanner,
|
||||||
|
//! with Markdown the one exception (`super::markdown`).
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Language {
|
||||||
|
C,
|
||||||
|
Coffeescript,
|
||||||
|
Cpp,
|
||||||
|
Csharp,
|
||||||
|
Dart,
|
||||||
|
Fish,
|
||||||
|
Go,
|
||||||
|
Java,
|
||||||
|
Javascript,
|
||||||
|
Json,
|
||||||
|
Kotlin,
|
||||||
|
Markdown,
|
||||||
|
Perl,
|
||||||
|
Php,
|
||||||
|
Python,
|
||||||
|
Ron,
|
||||||
|
Ruby,
|
||||||
|
Rust,
|
||||||
|
Shell,
|
||||||
|
Swift,
|
||||||
|
Toml,
|
||||||
|
Typescript,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Language {
|
||||||
|
/// Every value, for the same exhaustiveness check the Kotlin test runs
|
||||||
|
/// (`Language.entries`).
|
||||||
|
pub const ALL: [Language; 22] = [
|
||||||
|
Language::C,
|
||||||
|
Language::Coffeescript,
|
||||||
|
Language::Cpp,
|
||||||
|
Language::Csharp,
|
||||||
|
Language::Dart,
|
||||||
|
Language::Fish,
|
||||||
|
Language::Go,
|
||||||
|
Language::Java,
|
||||||
|
Language::Javascript,
|
||||||
|
Language::Json,
|
||||||
|
Language::Kotlin,
|
||||||
|
Language::Markdown,
|
||||||
|
Language::Perl,
|
||||||
|
Language::Php,
|
||||||
|
Language::Python,
|
||||||
|
Language::Ron,
|
||||||
|
Language::Ruby,
|
||||||
|
Language::Rust,
|
||||||
|
Language::Shell,
|
||||||
|
Language::Swift,
|
||||||
|
Language::Toml,
|
||||||
|
Language::Typescript,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`super::scan`] needs to know about one language -- data, not code,
|
||||||
|
/// so that adding a language is a row here rather than a branch anywhere.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Rules {
|
||||||
|
/// Words drawn as keywords. Only plain words; the scanner cannot reach
|
||||||
|
/// anything else.
|
||||||
|
pub keywords: HashSet<&'static str>,
|
||||||
|
/// Tokens that open a comment running to the end of the line.
|
||||||
|
pub line_comments: Vec<&'static str>,
|
||||||
|
/// Whether `line_comments` count only at the start of a word. The shells
|
||||||
|
/// need it: `$#`, `${#x}` and `a#b` are not comments.
|
||||||
|
pub line_comments_at_word_start: bool,
|
||||||
|
pub block_comment: Option<BlockComment>,
|
||||||
|
/// The string forms. The longest opener that matches wins, so `"""` is
|
||||||
|
/// tried before `"`.
|
||||||
|
pub quotes: Vec<Quote>,
|
||||||
|
pub attributes: Attributes,
|
||||||
|
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
|
||||||
|
pub raw_strings: bool,
|
||||||
|
/// Rust: `'` opens a character literal only when a backslash or one
|
||||||
|
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
||||||
|
pub lifetimes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct BlockComment {
|
||||||
|
pub open: &'static str,
|
||||||
|
pub close: &'static str,
|
||||||
|
pub nests: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One string form. `escapes` is whether a backslash escapes the closer
|
||||||
|
/// (and itself).
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Quote {
|
||||||
|
pub open: &'static str,
|
||||||
|
pub close: &'static str,
|
||||||
|
pub escapes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What opens a metadata span, of the shapes that exist across these languages.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub enum Attributes {
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
/// `@` and a word: Kotlin and Java annotations, Python decorators.
|
||||||
|
AtWord,
|
||||||
|
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
|
||||||
|
HashBracket,
|
||||||
|
/// `#` at the start of a line, to the end of it: the C preprocessor.
|
||||||
|
HashLine,
|
||||||
|
/// `[` at the start of a line through the matching `]`: a TOML table header.
|
||||||
|
LineBracket,
|
||||||
|
}
|
||||||
|
|
||||||
|
const C_STYLE: BlockComment = BlockComment {
|
||||||
|
open: "/*",
|
||||||
|
close: "*/",
|
||||||
|
nests: false,
|
||||||
|
};
|
||||||
|
const NESTING: BlockComment = BlockComment {
|
||||||
|
open: "/*",
|
||||||
|
close: "*/",
|
||||||
|
nests: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOUBLE: Quote = Quote {
|
||||||
|
open: "\"",
|
||||||
|
close: "\"",
|
||||||
|
escapes: true,
|
||||||
|
};
|
||||||
|
const SINGLE: Quote = Quote {
|
||||||
|
open: "'",
|
||||||
|
close: "'",
|
||||||
|
escapes: true,
|
||||||
|
};
|
||||||
|
const TRIPLE_DOUBLE: Quote = Quote {
|
||||||
|
open: "\"\"\"",
|
||||||
|
close: "\"\"\"",
|
||||||
|
escapes: true,
|
||||||
|
};
|
||||||
|
const TRIPLE_SINGLE: Quote = Quote {
|
||||||
|
open: "'''",
|
||||||
|
close: "'''",
|
||||||
|
escapes: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn words(list: &'static str) -> HashSet<&'static str> {
|
||||||
|
list.split_whitespace().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rules for one language. A `match` rather than a lazily-built map --
|
||||||
|
/// there is no once-per-process cost worth paying for in a language table
|
||||||
|
/// this small, and it sidesteps the Kotlin version's own workaround for
|
||||||
|
/// property initialization order.
|
||||||
|
pub fn rules_for(language: Language) -> Rules {
|
||||||
|
match language {
|
||||||
|
Language::C => Rules {
|
||||||
|
keywords: words(KEYWORDS_C),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::HashLine,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Cpp => Rules {
|
||||||
|
keywords: words(KEYWORDS_CPP),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::HashLine,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Csharp => Rules {
|
||||||
|
keywords: words(KEYWORDS_CSHARP),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
// `###` opens and closes a block comment and `#` opens a line one,
|
||||||
|
// which is why the scanner tries the block opener first.
|
||||||
|
Language::Coffeescript => Rules {
|
||||||
|
keywords: words(KEYWORDS_COFFEESCRIPT),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
block_comment: Some(BlockComment {
|
||||||
|
open: "###",
|
||||||
|
close: "###",
|
||||||
|
nests: false,
|
||||||
|
}),
|
||||||
|
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Dart => Rules {
|
||||||
|
keywords: words(KEYWORDS_DART),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(NESTING),
|
||||||
|
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Fish => Rules {
|
||||||
|
keywords: words(KEYWORDS_FISH),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
line_comments_at_word_start: true,
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Go => Rules {
|
||||||
|
keywords: words(KEYWORDS_GO),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![
|
||||||
|
DOUBLE,
|
||||||
|
SINGLE,
|
||||||
|
Quote {
|
||||||
|
open: "`",
|
||||||
|
close: "`",
|
||||||
|
escapes: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Java => Rules {
|
||||||
|
keywords: words(KEYWORDS_JAVA),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Javascript => Rules {
|
||||||
|
keywords: words(KEYWORDS_JAVASCRIPT),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![
|
||||||
|
DOUBLE,
|
||||||
|
SINGLE,
|
||||||
|
Quote {
|
||||||
|
open: "`",
|
||||||
|
close: "`",
|
||||||
|
escapes: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Json => Rules {
|
||||||
|
keywords: words(KEYWORDS_JSON),
|
||||||
|
quotes: vec![DOUBLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Kotlin => Rules {
|
||||||
|
keywords: words(KEYWORDS_KOTLIN),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(NESTING),
|
||||||
|
quotes: vec![
|
||||||
|
Quote {
|
||||||
|
open: "\"\"\"",
|
||||||
|
close: "\"\"\"",
|
||||||
|
escapes: false,
|
||||||
|
},
|
||||||
|
DOUBLE,
|
||||||
|
SINGLE,
|
||||||
|
],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Perl => Rules {
|
||||||
|
keywords: words(KEYWORDS_PERL),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Php => Rules {
|
||||||
|
keywords: words(KEYWORDS_PHP),
|
||||||
|
line_comments: vec!["//", "#"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Python => Rules {
|
||||||
|
keywords: words(KEYWORDS_PYTHON),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Ron => Rules {
|
||||||
|
keywords: words(KEYWORDS_RON),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(NESTING),
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
attributes: Attributes::HashBracket,
|
||||||
|
raw_strings: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Ruby => Rules {
|
||||||
|
keywords: words(KEYWORDS_RUBY),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Rust => Rules {
|
||||||
|
keywords: words(KEYWORDS_RUST),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(NESTING),
|
||||||
|
// No `'` here: `lifetimes` decides when one opens a character literal.
|
||||||
|
quotes: vec![DOUBLE],
|
||||||
|
attributes: Attributes::HashBracket,
|
||||||
|
raw_strings: true,
|
||||||
|
lifetimes: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Shell => Rules {
|
||||||
|
keywords: words(KEYWORDS_SHELL),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
line_comments_at_word_start: true,
|
||||||
|
// A shell's single quotes are literal: `'a\'` is not one string.
|
||||||
|
quotes: vec![
|
||||||
|
DOUBLE,
|
||||||
|
Quote {
|
||||||
|
open: "'",
|
||||||
|
close: "'",
|
||||||
|
escapes: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Swift => Rules {
|
||||||
|
keywords: words(KEYWORDS_SWIFT),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(NESTING),
|
||||||
|
quotes: vec![TRIPLE_DOUBLE, DOUBLE],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Toml => Rules {
|
||||||
|
keywords: words(KEYWORDS_TOML),
|
||||||
|
line_comments: vec!["#"],
|
||||||
|
quotes: vec![
|
||||||
|
TRIPLE_DOUBLE,
|
||||||
|
Quote {
|
||||||
|
open: "'''",
|
||||||
|
close: "'''",
|
||||||
|
escapes: false,
|
||||||
|
},
|
||||||
|
DOUBLE,
|
||||||
|
Quote {
|
||||||
|
open: "'",
|
||||||
|
close: "'",
|
||||||
|
escapes: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attributes: Attributes::LineBracket,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Language::Typescript => Rules {
|
||||||
|
keywords: words(KEYWORDS_TYPESCRIPT),
|
||||||
|
line_comments: vec!["//"],
|
||||||
|
block_comment: Some(C_STYLE),
|
||||||
|
quotes: vec![
|
||||||
|
DOUBLE,
|
||||||
|
SINGLE,
|
||||||
|
Quote {
|
||||||
|
open: "`",
|
||||||
|
close: "`",
|
||||||
|
escapes: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attributes: Attributes::AtWord,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
// Markdown has no token rules; see `super::markdown::scan_markdown`.
|
||||||
|
Language::Markdown => Rules::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 the
|
||||||
|
// Kotlin scanner replaced, so that no fence which was coloured there turns
|
||||||
|
// plain here either.
|
||||||
|
|
||||||
|
const KEYWORDS_C: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
const KEYWORDS_CPP: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
const KEYWORDS_CSHARP: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
const KEYWORDS_COFFEESCRIPT: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
const KEYWORDS_DART: &str =
|
||||||
|
"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.
|
||||||
|
const KEYWORDS_FISH: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
const KEYWORDS_GO: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
const KEYWORDS_JAVA: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
const KEYWORDS_JAVASCRIPT: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
const KEYWORDS_JSON: &str = "true false null";
|
||||||
|
|
||||||
|
const KEYWORDS_KOTLIN: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
const KEYWORDS_PERL: &str =
|
||||||
|
"__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";
|
||||||
|
|
||||||
|
const KEYWORDS_PHP: &str =
|
||||||
|
"__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";
|
||||||
|
|
||||||
|
const KEYWORDS_PYTHON: &str =
|
||||||
|
"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.
|
||||||
|
const KEYWORDS_RON: &str = "true false Some None inf NaN";
|
||||||
|
|
||||||
|
const KEYWORDS_RUBY: &str =
|
||||||
|
"__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";
|
||||||
|
|
||||||
|
const KEYWORDS_RUST: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
const KEYWORDS_SHELL: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
const KEYWORDS_SWIFT: &str =
|
||||||
|
"_ 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.
|
||||||
|
const KEYWORDS_TOML: &str = "true false inf nan";
|
||||||
|
|
||||||
|
const KEYWORDS_TYPESCRIPT: &str =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
/// The highlighter's language for a fence's info word, or `None` for one it
|
||||||
|
/// has no rules for. Also what `super::file_language` reads for a file's
|
||||||
|
/// extension -- one table, so a language added for fences is a language
|
||||||
|
/// added for files.
|
||||||
|
pub fn fence_language(name: Option<&str>) -> Option<Language> {
|
||||||
|
let name = name?.trim().to_lowercase();
|
||||||
|
FENCE_LANGUAGES
|
||||||
|
.iter()
|
||||||
|
.find(|(alias, _)| *alias == name)
|
||||||
|
.map(|(_, language)| *language)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The highlighter's language for a *file*, from its name.
|
||||||
|
///
|
||||||
|
/// 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 `None`.
|
||||||
|
pub fn file_language(name: &str) -> Option<Language> {
|
||||||
|
let dot = name.rfind('.')?;
|
||||||
|
if dot < 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
fence_language(Some(&name[dot + 1..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const FENCE_LANGUAGES: &[(&str, Language)] = &[
|
||||||
|
("kotlin", Language::Kotlin),
|
||||||
|
("kt", Language::Kotlin),
|
||||||
|
("kts", Language::Kotlin),
|
||||||
|
("rust", Language::Rust),
|
||||||
|
("rs", Language::Rust),
|
||||||
|
("sh", Language::Shell),
|
||||||
|
("bash", Language::Shell),
|
||||||
|
("shell", Language::Shell),
|
||||||
|
("zsh", Language::Shell),
|
||||||
|
("console", Language::Shell),
|
||||||
|
("python", Language::Python),
|
||||||
|
("py", Language::Python),
|
||||||
|
("javascript", Language::Javascript),
|
||||||
|
("js", Language::Javascript),
|
||||||
|
("jsx", Language::Javascript),
|
||||||
|
("typescript", Language::Typescript),
|
||||||
|
("ts", Language::Typescript),
|
||||||
|
("tsx", Language::Typescript),
|
||||||
|
("java", Language::Java),
|
||||||
|
("c", Language::C),
|
||||||
|
("h", Language::C),
|
||||||
|
("cpp", Language::Cpp),
|
||||||
|
("c++", Language::Cpp),
|
||||||
|
("cc", Language::Cpp),
|
||||||
|
("hpp", Language::Cpp),
|
||||||
|
("csharp", Language::Csharp),
|
||||||
|
("cs", Language::Csharp),
|
||||||
|
("c#", Language::Csharp),
|
||||||
|
("go", Language::Go),
|
||||||
|
("golang", Language::Go),
|
||||||
|
("swift", Language::Swift),
|
||||||
|
("dart", Language::Dart),
|
||||||
|
("ruby", Language::Ruby),
|
||||||
|
("rb", Language::Ruby),
|
||||||
|
("php", Language::Php),
|
||||||
|
("perl", Language::Perl),
|
||||||
|
("pl", Language::Perl),
|
||||||
|
("coffeescript", Language::Coffeescript),
|
||||||
|
("coffee", Language::Coffeescript),
|
||||||
|
("ron", Language::Ron),
|
||||||
|
("toml", Language::Toml),
|
||||||
|
("fish", Language::Fish),
|
||||||
|
("json", Language::Json),
|
||||||
|
("markdown", Language::Markdown),
|
||||||
|
("md", Language::Markdown),
|
||||||
|
];
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
//! Markdown read into the spans that carry a colour -- a ```markdown fence
|
||||||
|
//! in a reply, and a `.md` file in the viewer. Ported from
|
||||||
|
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
|
||||||
|
//! scanner rather than a row of [`super::Rules`] (what a character means
|
||||||
|
//! depends on where it sits, not on what it is) and why an indented code
|
||||||
|
//! block is deliberately not recognised.
|
||||||
|
//!
|
||||||
|
//! Structure is read a line at a time and each line's prose left to right,
|
||||||
|
//! except the two decisions that are not: a fenced block is state carried
|
||||||
|
//! forward, and a table is found by its delimiter row, which comes after
|
||||||
|
//! the header it belongs to (the one place here that looks ahead).
|
||||||
|
|
||||||
|
use super::{Kind, Span};
|
||||||
|
|
||||||
|
/// The characters an unordered list may be bulleted with.
|
||||||
|
const BULLETS: &str = "-*+";
|
||||||
|
/// The characters a thematic break, or a setext heading's underline, can be
|
||||||
|
/// drawn with.
|
||||||
|
const RULE_MARKERS: &str = "-*_=";
|
||||||
|
/// The characters that can open emphasis, strong emphasis or a strikethrough.
|
||||||
|
const EMPHASIS: &str = "*_~";
|
||||||
|
/// Characters that end a bare URL wherever they appear, and ones only
|
||||||
|
/// trimmed off the end.
|
||||||
|
const URL_STOPS: &str = "<>\"'`|";
|
||||||
|
const URL_TRAILING: &str = ".,:;!?";
|
||||||
|
|
||||||
|
pub fn scan_markdown(code: &str) -> Vec<Span> {
|
||||||
|
MarkdownScanner::new(code).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MarkdownScanner {
|
||||||
|
code: Vec<char>,
|
||||||
|
spans: Vec<Span>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarkdownScanner {
|
||||||
|
fn new(code: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.chars().collect(),
|
||||||
|
spans: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(mut self) -> Vec<Span> {
|
||||||
|
let mut at = 0usize;
|
||||||
|
// The delimiter run that opened the fenced block we are inside, or
|
||||||
|
// None between them.
|
||||||
|
let mut fence: Option<Vec<char>> = None;
|
||||||
|
// Whether the row above was part of a table, which is what makes
|
||||||
|
// this one a body row.
|
||||||
|
let mut table = false;
|
||||||
|
loop {
|
||||||
|
let end = self.line_end(at);
|
||||||
|
if let Some(open) = fence.clone() {
|
||||||
|
// 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.
|
||||||
|
self.emit(at, end, Kind::String);
|
||||||
|
if self.closes_fence(at, end, &open) {
|
||||||
|
fence = None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let opened = self.opens_fence(at, end);
|
||||||
|
if opened.is_some() {
|
||||||
|
table = false;
|
||||||
|
fence = opened;
|
||||||
|
} else {
|
||||||
|
table = self.row(at, end, table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end == self.code.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
at = end + 1;
|
||||||
|
}
|
||||||
|
self.spans
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end of the line beginning at `at`: the newline, or the end of the text.
|
||||||
|
fn line_end(&self, at: usize) -> usize {
|
||||||
|
self.code[at..]
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == '\n')
|
||||||
|
.map(|p| at + p)
|
||||||
|
.unwrap_or(self.code.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line that is not inside a fence, and whether the table it may be
|
||||||
|
/// part of is still open.
|
||||||
|
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
|
||||||
|
if self.table_delimiter(start, end) {
|
||||||
|
let indented = self.indented(start, end);
|
||||||
|
self.emit(indented, end, Kind::Mark);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let header = end < self.code.len() && self.table_delimiter(end + 1, self.line_end(end + 1));
|
||||||
|
if (table || header) && self.has_pipe(start, end) {
|
||||||
|
self.table_row(start, end);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.structure(start, end);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line of nothing but pipes, dashes, alignment colons and space, with
|
||||||
|
/// one of each needed.
|
||||||
|
fn table_delimiter(&self, start: usize, end: usize) -> bool {
|
||||||
|
let mut dashes = false;
|
||||||
|
let mut pipes = false;
|
||||||
|
for at in self.indented(start, end)..end {
|
||||||
|
match self.code[at] {
|
||||||
|
'-' => dashes = true,
|
||||||
|
'|' => pipes = true,
|
||||||
|
':' | ' ' | '\t' => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dashes && pipes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_pipe(&self, start: usize, end: usize) -> bool {
|
||||||
|
let mut at = start;
|
||||||
|
while at < end {
|
||||||
|
if self.code[at] == '\\' {
|
||||||
|
at += 2;
|
||||||
|
} else if self.code[at] == '|' {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table row: the pipes are the structure, and what is between them is prose.
|
||||||
|
fn table_row(&mut self, start: usize, end: usize) {
|
||||||
|
let mut at = self.indented(start, end);
|
||||||
|
let mut cell = at;
|
||||||
|
while at < end {
|
||||||
|
match self.code[at] {
|
||||||
|
'\\' => at += 2,
|
||||||
|
'|' => {
|
||||||
|
self.inline(cell, at);
|
||||||
|
self.emit(at, at + 1, Kind::Mark);
|
||||||
|
at += 1;
|
||||||
|
cell = at;
|
||||||
|
}
|
||||||
|
_ => at += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.inline(cell, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spans, coalesced with the one before when they touch and agree.
|
||||||
|
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
|
||||||
|
if end <= start {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(last) = self.spans.last_mut()
|
||||||
|
&& last.kind == kind
|
||||||
|
&& last.end == start
|
||||||
|
{
|
||||||
|
last.end = end;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.spans.push(Span { start, end, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first character of the line at or after `start` that is not indentation.
|
||||||
|
fn indented(&self, start: usize, end: usize) -> usize {
|
||||||
|
let mut at = start;
|
||||||
|
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
at
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The run of backticks or tildes that could open or close a fence on
|
||||||
|
/// this line, or `None`.
|
||||||
|
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
|
||||||
|
let at = self.indented(start, end);
|
||||||
|
if at == end {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let marker = self.code[at];
|
||||||
|
if marker != '`' && marker != '~' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut run = at;
|
||||||
|
while run < end && self.code[run] == marker {
|
||||||
|
run += 1;
|
||||||
|
}
|
||||||
|
if run - at >= 3 { Some((at, run)) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws an opening fence line and answers its delimiter, or `None` if
|
||||||
|
/// this is not one.
|
||||||
|
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
|
||||||
|
let (run_start, run_end) = self.fence_run(start, end)?;
|
||||||
|
self.emit(run_start, run_end, Kind::String);
|
||||||
|
// The info word is what the fence is a fence *of*, which is
|
||||||
|
// metadata about the block rather than part of it.
|
||||||
|
let indented = self.indented(run_end, end);
|
||||||
|
self.emit(indented, end, Kind::Metadata);
|
||||||
|
Some(self.code[run_start..run_end].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this line closes a fence opened by `open`: the same
|
||||||
|
/// character, at least as many of them, and nothing else on the line.
|
||||||
|
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
|
||||||
|
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if self.code[run_start] != open[0] || run_end - run_start < open.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.indented(run_end, end) == end
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One ordinary line: what its opening characters make it, and then its prose.
|
||||||
|
fn structure(&mut self, start: usize, end: usize) {
|
||||||
|
let mut at = start;
|
||||||
|
// 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 && self.code[at] == '>' {
|
||||||
|
at += 1;
|
||||||
|
self.emit(at - 1, at, Kind::Mark);
|
||||||
|
at = self.indented(at, end);
|
||||||
|
}
|
||||||
|
if at == end {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.heading(at, end) || self.thematic_break(at, end) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text_start = self.bullet(at, end);
|
||||||
|
self.inline(text_start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#` to `######` and a space. Without the space it is a word
|
||||||
|
/// beginning with a hash.
|
||||||
|
fn heading(&mut self, start: usize, end: usize) -> bool {
|
||||||
|
let mut at = start;
|
||||||
|
while at < end && self.code[at] == '#' {
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
let depth = at - start;
|
||||||
|
if !(1..=6).contains(&depth) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if at < end && self.code[at] != ' ' && self.code[at] != '\t' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.emit(start, end, Kind::Keyword);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line made of one repeated rule character and nothing else.
|
||||||
|
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
||||||
|
let marker = self.code[start];
|
||||||
|
if !RULE_MARKERS.contains(marker) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut seen = 0usize;
|
||||||
|
for at in start..end {
|
||||||
|
let c = self.code[at];
|
||||||
|
if c == marker {
|
||||||
|
seen += 1;
|
||||||
|
} else if !c.is_whitespace() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen < if marker == '=' { 1 } else { 3 } {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.emit(start, end, Kind::Mark);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws a list marker if the line opens with one, and answers where
|
||||||
|
/// the item's text starts.
|
||||||
|
fn bullet(&mut self, start: usize, end: usize) -> usize {
|
||||||
|
let marker = self.code[start];
|
||||||
|
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
|
||||||
|
self.emit(start, start + 1, Kind::Mark);
|
||||||
|
return self.indented(start + 1, end);
|
||||||
|
}
|
||||||
|
let mut digits = start;
|
||||||
|
while digits < end && self.code[digits].is_ascii_digit() {
|
||||||
|
digits += 1;
|
||||||
|
}
|
||||||
|
let delimiter = self.code.get(digits).copied();
|
||||||
|
if digits > start
|
||||||
|
&& (delimiter == Some('.') || delimiter == Some(')'))
|
||||||
|
&& self.space_or_end(digits + 1, end)
|
||||||
|
{
|
||||||
|
self.emit(start, digits + 1, Kind::Mark);
|
||||||
|
return self.indented(digits + 1, end);
|
||||||
|
}
|
||||||
|
start
|
||||||
|
}
|
||||||
|
|
||||||
|
fn space_or_end(&self, at: usize, end: usize) -> bool {
|
||||||
|
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inline forms, left to right. Every branch answers a position
|
||||||
|
/// strictly after `start` of its call, so this terminates.
|
||||||
|
fn inline(&mut self, start: usize, end: usize) {
|
||||||
|
let mut at = start;
|
||||||
|
while at < end {
|
||||||
|
let c = self.code[at];
|
||||||
|
at = if c == '\\' {
|
||||||
|
// A backslash takes the character after it out of the
|
||||||
|
// running entirely, which is how `\*` stays an asterisk
|
||||||
|
// rather than opening emphasis.
|
||||||
|
at + 2
|
||||||
|
} else if c == '`' {
|
||||||
|
self.code_span(at, end)
|
||||||
|
} else if c == '[' {
|
||||||
|
self.link(at, at, end)
|
||||||
|
} else if c == '!' && self.code.get(at + 1) == Some(&'[') {
|
||||||
|
self.link(at, at + 1, end)
|
||||||
|
} else if c == '<' {
|
||||||
|
self.autolink(at, end)
|
||||||
|
} else if EMPHASIS.contains(c) {
|
||||||
|
self.emphasis(at, end)
|
||||||
|
} else {
|
||||||
|
self.url(at, end).unwrap_or(at + 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
|
||||||
|
fn code_span(&mut self, start: usize, end: usize) -> usize {
|
||||||
|
let mut open = start;
|
||||||
|
while open < end && self.code[open] == '`' {
|
||||||
|
open += 1;
|
||||||
|
}
|
||||||
|
let ticks = open - start;
|
||||||
|
let mut at = open;
|
||||||
|
while at < end {
|
||||||
|
if self.code[at] != '`' {
|
||||||
|
at += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut close = at;
|
||||||
|
while close < end && self.code[close] == '`' {
|
||||||
|
close += 1;
|
||||||
|
}
|
||||||
|
if close - at == ticks {
|
||||||
|
self.emit(start, close, Kind::String);
|
||||||
|
return close;
|
||||||
|
}
|
||||||
|
at = close;
|
||||||
|
}
|
||||||
|
// Nothing closes it on this line, so those were ordinary backticks.
|
||||||
|
open
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[text](destination)`, and the same with a leading `!` for an image.
|
||||||
|
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
||||||
|
let mut depth = 0i32;
|
||||||
|
let mut close = bracket;
|
||||||
|
while close < end {
|
||||||
|
match self.code[close] {
|
||||||
|
'\\' => close += 1,
|
||||||
|
'[' => depth += 1,
|
||||||
|
']' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
close += 1;
|
||||||
|
}
|
||||||
|
if close >= end {
|
||||||
|
return start + 1;
|
||||||
|
}
|
||||||
|
let destination = close + 1;
|
||||||
|
if self.code.get(destination) != Some(&'(') {
|
||||||
|
return start + 1;
|
||||||
|
}
|
||||||
|
let Some(paren_rel) = self.code[destination..].iter().position(|&c| c == ')') else {
|
||||||
|
return start + 1;
|
||||||
|
};
|
||||||
|
let paren = destination + paren_rel;
|
||||||
|
if paren >= end {
|
||||||
|
return start + 1;
|
||||||
|
}
|
||||||
|
self.emit(start, bracket + 1, Kind::Mark);
|
||||||
|
self.inline(bracket + 1, close);
|
||||||
|
self.emit(close, destination, Kind::Mark);
|
||||||
|
self.emit(destination, paren + 1, Kind::Metadata);
|
||||||
|
paren + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<https://example.com>` and `<name@example.com>`, drawn as the
|
||||||
|
/// destination they are.
|
||||||
|
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
||||||
|
let mut at = start + 1;
|
||||||
|
let mut addressed = false;
|
||||||
|
while at < end {
|
||||||
|
let c = self.code[at];
|
||||||
|
if c.is_whitespace() || c == '<' {
|
||||||
|
return start + 1;
|
||||||
|
}
|
||||||
|
if c == '>' {
|
||||||
|
if !addressed {
|
||||||
|
return start + 1;
|
||||||
|
}
|
||||||
|
self.emit(start, at + 1, Kind::Metadata);
|
||||||
|
return at + 1;
|
||||||
|
}
|
||||||
|
if c == ':' || c == '@' {
|
||||||
|
addressed = true;
|
||||||
|
}
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
start + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bare `scheme://...` written in prose, or `None` if one does not
|
||||||
|
/// start here.
|
||||||
|
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
|
||||||
|
if start > 0 && is_word(self.code[start - 1]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut scheme = start;
|
||||||
|
while scheme < end && self.code[scheme].is_alphabetic() {
|
||||||
|
scheme += 1;
|
||||||
|
}
|
||||||
|
if scheme == start || !starts_with(&self.code, scheme, "://") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body = scheme + 3;
|
||||||
|
let mut at = body;
|
||||||
|
let mut openers = 0i32;
|
||||||
|
let mut closers = 0i32;
|
||||||
|
while at < end && !self.code[at].is_whitespace() && !URL_STOPS.contains(self.code[at]) {
|
||||||
|
if self.code[at] == '(' {
|
||||||
|
openers += 1;
|
||||||
|
} else if self.code[at] == ')' {
|
||||||
|
closers += 1;
|
||||||
|
}
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
while at > body {
|
||||||
|
let last = self.code[at - 1];
|
||||||
|
if URL_TRAILING.contains(last) {
|
||||||
|
at -= 1;
|
||||||
|
} else if last == ')' && closers > openers {
|
||||||
|
closers -= 1;
|
||||||
|
at -= 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at == body {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.emit(start, at, Kind::Metadata);
|
||||||
|
Some(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
|
||||||
|
/// all.
|
||||||
|
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
||||||
|
let marker = self.code[start];
|
||||||
|
let mut open = start;
|
||||||
|
while open < end && self.code[open] == marker {
|
||||||
|
open += 1;
|
||||||
|
}
|
||||||
|
let length = open - start;
|
||||||
|
if marker == '~' && length != 2 {
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
if length > 3 {
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
if open == end || self.code[open].is_whitespace() {
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
if marker == '_' && start > 0 && is_word(self.code[start - 1]) {
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
let mut at = open;
|
||||||
|
while at < end {
|
||||||
|
if self.code[at] == '\\' {
|
||||||
|
at += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.code[at] != marker {
|
||||||
|
at += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut close = at;
|
||||||
|
while close < end && self.code[close] == marker {
|
||||||
|
close += 1;
|
||||||
|
}
|
||||||
|
let finish = at + length;
|
||||||
|
if close - at >= length
|
||||||
|
&& !self.code[at - 1].is_whitespace()
|
||||||
|
&& !(marker == '_' && finish < end && is_word(self.code[finish]))
|
||||||
|
{
|
||||||
|
self.emit(start, finish, Kind::Literal);
|
||||||
|
return finish;
|
||||||
|
}
|
||||||
|
at = close;
|
||||||
|
}
|
||||||
|
open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_word(c: char) -> bool {
|
||||||
|
c.is_alphanumeric() || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
fn starts_with(code: &[char], at: usize, token: &str) -> bool {
|
||||||
|
let token: Vec<char> = token.chars().collect();
|
||||||
|
if at + token.len() > code.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
code[at..at + token.len()] == token[..]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::{Kind, Language, span_text, spans_of};
|
||||||
|
|
||||||
|
fn spans(code: &str, kind: Kind) -> Vec<String> {
|
||||||
|
let chars: Vec<char> = code.chars().collect();
|
||||||
|
spans_of(code, Language::Markdown)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.kind == kind)
|
||||||
|
.map(|s| span_text(&chars, &s))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_spans(code: &str, kind: Kind, expected: &[&str]) {
|
||||||
|
assert_eq!(spans(code, kind), expected.to_vec(), "{kind:?} in: {code}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_heading_is_coloured_whole_and_a_hash_inside_a_word_is_not_one() {
|
||||||
|
let code = "## Layout\nissue #12 is fixed\n#hashtag";
|
||||||
|
assert_spans(code, Kind::Keyword, &["## Layout"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seven_hashes_are_not_a_heading() {
|
||||||
|
assert_spans("####### deep", Kind::Keyword, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fence_carries_its_language_as_metadata_and_its_body_as_one_string() {
|
||||||
|
let code = "text\n```kotlin\nval x = 1\n```\nmore";
|
||||||
|
assert_spans(code, Kind::Metadata, &["kotlin"]);
|
||||||
|
assert_spans(code, Kind::String, &["```", "val x = 1", "```"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_longer_fence_is_not_closed_by_a_shorter_one_and_a_heading_inside_it_is_not_a_heading() {
|
||||||
|
let code = "````\n```\n# not a heading\n````\nafter";
|
||||||
|
assert_spans(code, Kind::Keyword, &[]);
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Kind::String,
|
||||||
|
&["````", "```", "# not a heading", "````"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unclosed_fence_runs_to_the_end_rather_than_panicking() {
|
||||||
|
assert_spans("```\nstill going", Kind::String, &["```", "still going"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_markers_and_quote_markers_colour_without_their_text() {
|
||||||
|
let code = "- one\n2. two\n> quoted";
|
||||||
|
assert_spans(code, Kind::Mark, &["-", "2.", ">"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rule_and_a_setext_underline_are_the_same_mark() {
|
||||||
|
assert_spans("Title\n=====\n\n---", Kind::Mark, &["=====", "---"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emphasis_needs_something_on_both_sides_of_it() {
|
||||||
|
assert_spans(
|
||||||
|
"**bold** and *thin*",
|
||||||
|
Kind::Literal,
|
||||||
|
&["**bold**", "*thin*"],
|
||||||
|
);
|
||||||
|
assert_spans("a * b * c and *p = *q", Kind::Literal, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_underscore_inside_a_word_emphasises_nothing() {
|
||||||
|
assert_spans("snake_case_name and _real_", Kind::Literal, &["_real_"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_code_span_holds_a_backtick_when_opened_with_two() {
|
||||||
|
assert_spans("``a ` b`` and `c`", Kind::String, &["``a ` b``", "`c`"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unclosed_code_span_is_ordinary_text() {
|
||||||
|
assert_spans("a ` b", Kind::String, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_marks_its_brackets_and_colours_its_destination() {
|
||||||
|
let code = "see [the plan](PLAN.md) now";
|
||||||
|
assert_spans(code, Kind::Mark, &["[", "]"]);
|
||||||
|
assert_spans(code, Kind::Metadata, &["(PLAN.md)"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_is_found_by_its_delimiter_row_and_pipes_elsewhere_are_plain() {
|
||||||
|
let code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Kind::Mark,
|
||||||
|
&["|", "|", "|", "|---|---|", "|", "|", "|"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_without_outer_pipes_still_colours_and_the_table_ends_with_the_rows() {
|
||||||
|
let code = "a | b\n--- | ---\nnot a row";
|
||||||
|
assert_spans(code, Kind::Mark, &["|", "--- | ---"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_autolink_colours_and_an_html_tag_does_not() {
|
||||||
|
let code = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Kind::Metadata,
|
||||||
|
&["<https://example.com>", "<a@b.com>", "http://x"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bare_url_gives_back_the_sentences_punctuation() {
|
||||||
|
assert_spans(
|
||||||
|
"see https://example.com/a., and ssh://host/x)",
|
||||||
|
Kind::Metadata,
|
||||||
|
&["https://example.com/a", "ssh://host/x"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bracket_a_url_opened_itself_stays_in_it() {
|
||||||
|
assert_spans(
|
||||||
|
"https://en.wikipedia.org/wiki/A_(b) here",
|
||||||
|
Kind::Metadata,
|
||||||
|
&["https://en.wikipedia.org/wiki/A_(b)"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_url_inside_a_link_destination_is_not_coloured_twice() {
|
||||||
|
assert_spans(
|
||||||
|
"[x](https://example.com)",
|
||||||
|
Kind::Metadata,
|
||||||
|
&["(https://example.com)"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bracket_with_no_destination_after_it_is_left_plain() {
|
||||||
|
assert_spans("an [aside] here", Kind::Mark, &[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
//! `code` read once, left to right, into the spans that carry a colour.
|
||||||
|
//! Ported from `app/.../Highlighter.kt`.
|
||||||
|
//!
|
||||||
|
//! 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 this replaced 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 panics: 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.
|
||||||
|
//!
|
||||||
|
//! **Indices are char offsets, not byte offsets** -- the scanner works over
|
||||||
|
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
|
||||||
|
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
|
||||||
|
//! back into the text it covers.
|
||||||
|
|
||||||
|
pub mod languages;
|
||||||
|
pub mod markdown;
|
||||||
|
|
||||||
|
pub use languages::{
|
||||||
|
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What a span of code is, in the terms a palette has a colour for.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Kind {
|
||||||
|
Keyword,
|
||||||
|
String,
|
||||||
|
Literal,
|
||||||
|
Comment,
|
||||||
|
Metadata,
|
||||||
|
Punctuation,
|
||||||
|
Mark,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Span {
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
pub kind: Kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text a [`Span`] covers, for a caller working in char indices (every
|
||||||
|
/// test in this module, and any UI that also holds `code` as `Vec<char>`).
|
||||||
|
pub fn span_text(code: &[char], span: &Span) -> String {
|
||||||
|
code[span.start..span.end].iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The spans `language` colours in `code` -- the one way to ask, whatever
|
||||||
|
/// the language turns out to be made of. `None` draws plain.
|
||||||
|
pub fn spans_of(code: &str, language: Language) -> Vec<Span> {
|
||||||
|
if language == Language::Markdown {
|
||||||
|
markdown::scan_markdown(code)
|
||||||
|
} else {
|
||||||
|
scan(code, &rules_for(language))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `code` read into the spans [`Rules`] describes. Also reachable directly
|
||||||
|
/// for a caller that already has a [`Rules`] (there is currently only one:
|
||||||
|
/// [`spans_of`]), kept public because the Kotlin original exposed it the
|
||||||
|
/// same way.
|
||||||
|
pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
|
||||||
|
Scanner::new(code, rules).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characters coloured as punctuation, and as marks. Both sets are the ones
|
||||||
|
/// the library this replaced used.
|
||||||
|
const PUNCTUATION: &str = ",.:;";
|
||||||
|
const MARKS: &str = "()={}<>-+[]|&";
|
||||||
|
|
||||||
|
struct Scanner<'a> {
|
||||||
|
code: Vec<char>,
|
||||||
|
rules: &'a Rules,
|
||||||
|
spans: Vec<Span>,
|
||||||
|
at: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Scanner<'a> {
|
||||||
|
fn new(code: &str, rules: &'a Rules) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.chars().collect(),
|
||||||
|
rules,
|
||||||
|
spans: Vec::new(),
|
||||||
|
at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(mut self) -> Vec<Span> {
|
||||||
|
while self.at < self.code.len() {
|
||||||
|
// Every branch that answers true has advanced `self.at`, so
|
||||||
|
// this terminates.
|
||||||
|
let consumed = self.block_comment()
|
||||||
|
|| self.line_comment()
|
||||||
|
|| self.raw_string()
|
||||||
|
|| self.character_or_lifetime()
|
||||||
|
|| self.string()
|
||||||
|
|| self.attribute()
|
||||||
|
|| self.number()
|
||||||
|
|| self.word()
|
||||||
|
|| self.single_character();
|
||||||
|
if !consumed {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.spans
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(&mut self, start: usize, kind: Kind) {
|
||||||
|
if self.at > start {
|
||||||
|
self.spans.push(Span {
|
||||||
|
start,
|
||||||
|
end: self.at,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn starts(&self, token: &str) -> bool {
|
||||||
|
starts_with_at(&self.code, self.at, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a line comment token here opens one; see
|
||||||
|
/// [`Rules::line_comments_at_word_start`].
|
||||||
|
fn at_word_start(&self) -> bool {
|
||||||
|
self.at == 0
|
||||||
|
|| self.code[self.at - 1].is_whitespace()
|
||||||
|
|| ";|&(".contains(self.code[self.at - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether only whitespace stands between the start of this line and here.
|
||||||
|
fn at_line_start(&self) -> bool {
|
||||||
|
let mut back = self.at as isize - 1;
|
||||||
|
while back >= 0 && self.code[back as usize] != '\n' {
|
||||||
|
if !self.code[back as usize].is_whitespace() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
back -= 1;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_to_end_of_line(&mut self) {
|
||||||
|
while self.at < self.code.len() && self.code[self.at] != '\n' {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// From an open bracket through the one that matches it, or to the end
|
||||||
|
/// if none does.
|
||||||
|
fn advance_to_matching_bracket(&mut self) {
|
||||||
|
let mut depth = 0i32;
|
||||||
|
while self.at < self.code.len() {
|
||||||
|
match self.code[self.at] {
|
||||||
|
'[' => depth += 1,
|
||||||
|
']' => depth -= 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.at += 1;
|
||||||
|
if depth == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_comment(&mut self) -> bool {
|
||||||
|
let Some(comment) = self.rules.block_comment else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !self.starts(comment.open) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = self.at;
|
||||||
|
self.at += comment.open.chars().count();
|
||||||
|
let mut depth = 1i32;
|
||||||
|
while self.at < self.code.len() && 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 self.starts(comment.close) {
|
||||||
|
depth -= 1;
|
||||||
|
self.at += comment.close.chars().count();
|
||||||
|
} else if comment.nests && self.starts(comment.open) {
|
||||||
|
depth += 1;
|
||||||
|
self.at += comment.open.chars().count();
|
||||||
|
} else {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.emit(start, Kind::Comment);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_comment(&mut self) -> bool {
|
||||||
|
if !self.rules.line_comments.iter().any(|c| self.starts(c)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.rules.line_comments_at_word_start && !self.at_word_start() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = self.at;
|
||||||
|
self.advance_to_end_of_line();
|
||||||
|
self.emit(start, Kind::Comment);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
|
||||||
|
fn raw_string(&mut self) -> bool {
|
||||||
|
if !self.rules.raw_strings {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut ahead = self.at;
|
||||||
|
if self.code.get(ahead) == Some(&'b') {
|
||||||
|
ahead += 1;
|
||||||
|
}
|
||||||
|
if self.code.get(ahead) != Some(&'r') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ahead += 1;
|
||||||
|
let mut hashes = 0usize;
|
||||||
|
while self.code.get(ahead) == Some(&'#') {
|
||||||
|
ahead += 1;
|
||||||
|
hashes += 1;
|
||||||
|
}
|
||||||
|
if self.code.get(ahead) != Some(&'"') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = self.at;
|
||||||
|
let closer: String = std::iter::once('"')
|
||||||
|
.chain(std::iter::repeat_n('#', hashes))
|
||||||
|
.collect();
|
||||||
|
let closer_chars: Vec<char> = closer.chars().collect();
|
||||||
|
let closed = find_from(&self.code, ahead + 1, &closer_chars);
|
||||||
|
self.at = match closed {
|
||||||
|
Some(index) => index + closer_chars.len(),
|
||||||
|
None => self.code.len(),
|
||||||
|
};
|
||||||
|
self.emit(start, Kind::String);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See [`Rules::lifetimes`]: an apostrophe that is not a character
|
||||||
|
/// literal opens nothing.
|
||||||
|
fn character_or_lifetime(&mut self) -> bool {
|
||||||
|
if !self.rules.lifetimes || self.code[self.at] != '\'' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(&next) = self.code.get(self.at + 1) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if next == '\\' || self.code.get(self.at + 2) == Some(&'\'') {
|
||||||
|
self.quoted(Quote {
|
||||||
|
open: "'",
|
||||||
|
close: "'",
|
||||||
|
escapes: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string(&mut self) -> bool {
|
||||||
|
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
|
||||||
|
// than an empty string followed by a quote.
|
||||||
|
let mut quote: Option<Quote> = None;
|
||||||
|
for candidate in &self.rules.quotes {
|
||||||
|
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
||||||
|
if self.starts(candidate.open) && candidate.open.chars().count() > current_len {
|
||||||
|
quote = Some(*candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(quote) = quote else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.quoted(quote);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quoted(&mut self, quote: Quote) {
|
||||||
|
let start = self.at;
|
||||||
|
self.at += quote.open.chars().count();
|
||||||
|
while self.at < self.code.len() {
|
||||||
|
if quote.escapes && self.code[self.at] == '\\' && self.at + 1 < self.code.len() {
|
||||||
|
self.at += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.starts(quote.close) {
|
||||||
|
self.at += quote.close.chars().count();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
self.at = self.at.min(self.code.len());
|
||||||
|
self.emit(start, Kind::String);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attribute(&mut self) -> bool {
|
||||||
|
let start = self.at;
|
||||||
|
match self.rules.attributes {
|
||||||
|
Attributes::None => return false,
|
||||||
|
Attributes::AtWord => {
|
||||||
|
if self.code[self.at] != '@' || !is_word_start(self.code.get(self.at + 1).copied())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.at += 1;
|
||||||
|
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Attributes::HashBracket => {
|
||||||
|
if self.code[self.at] != '#' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut ahead = self.at + 1;
|
||||||
|
if self.code.get(ahead) == Some(&'!') {
|
||||||
|
ahead += 1;
|
||||||
|
}
|
||||||
|
if self.code.get(ahead) != Some(&'[') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.at = ahead;
|
||||||
|
self.advance_to_matching_bracket();
|
||||||
|
}
|
||||||
|
Attributes::HashLine => {
|
||||||
|
if self.code[self.at] != '#' || !self.at_line_start() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.advance_to_end_of_line();
|
||||||
|
}
|
||||||
|
Attributes::LineBracket => {
|
||||||
|
if self.code[self.at] != '[' || !self.at_line_start() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.advance_to_matching_bracket();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.emit(start, Kind::Metadata);
|
||||||
|
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.
|
||||||
|
fn number(&mut self) -> bool {
|
||||||
|
if !self.code[self.at].is_ascii_digit() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = self.at;
|
||||||
|
while self.at < self.code.len() {
|
||||||
|
let c = self.code[self.at];
|
||||||
|
if c.is_alphanumeric() || c == '_' || c == '.' {
|
||||||
|
self.at += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.emit(start, Kind::Literal);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn word(&mut self) -> bool {
|
||||||
|
if !is_word_start(Some(self.code[self.at])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = self.at;
|
||||||
|
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
|
||||||
|
self.at += 1;
|
||||||
|
}
|
||||||
|
let word: String = self.code[start..self.at].iter().collect();
|
||||||
|
if self.rules.keywords.contains(word.as_str()) {
|
||||||
|
self.emit(start, Kind::Keyword);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn single_character(&mut self) -> bool {
|
||||||
|
let kind = if PUNCTUATION.contains(self.code[self.at]) {
|
||||||
|
Kind::Punctuation
|
||||||
|
} else if MARKS.contains(self.code[self.at]) {
|
||||||
|
Kind::Mark
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.at += 1;
|
||||||
|
self.emit(self.at - 1, kind);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_word_start(c: Option<char>) -> bool {
|
||||||
|
matches!(c, Some(c) if c.is_alphabetic() || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_word_part(c: char) -> bool {
|
||||||
|
c.is_alphanumeric() || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `code[at..]` starts with `token`, both read as chars.
|
||||||
|
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
||||||
|
let token: Vec<char> = token.chars().collect();
|
||||||
|
if at + token.len() > code.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
code[at..at + token.len()] == token[..]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first index at or after `from` where `code` contains `needle`, or
|
||||||
|
/// `None`.
|
||||||
|
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
|
||||||
|
if needle.is_empty() || from > code.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
(from..=code.len().saturating_sub(needle.len())).find(|&i| code[i..i + needle.len()] == *needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn spans(code: &str, language: Language, kind: Kind) -> Vec<String> {
|
||||||
|
let chars: Vec<char> = code.chars().collect();
|
||||||
|
spans_of(code, language)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.kind == kind)
|
||||||
|
.map(|s| span_text(&chars, &s))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_spans(code: &str, language: Language, kind: Kind, expected: &[&str]) {
|
||||||
|
assert_eq!(
|
||||||
|
spans(code, language, kind),
|
||||||
|
expected.to_vec(),
|
||||||
|
"{kind:?} in: {code}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quoted_glob_is_one_string_not_a_comment() {
|
||||||
|
assert_spans("x '*/a/*'", Language::Shell, Kind::String, &["'*/a/*'"]);
|
||||||
|
assert_spans("x '*/a/*'", Language::Shell, Kind::Comment, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_find_with_globs_has_no_comment_in_it() {
|
||||||
|
let code = "find . -path '*/.git/*' -prune -o -name '*.kt' -print";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Shell,
|
||||||
|
Kind::String,
|
||||||
|
&["'*/.git/*'", "'*.kt'"],
|
||||||
|
);
|
||||||
|
assert_spans(code, Language::Shell, Kind::Comment, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_url_does_not_comment_out_the_rest_of_a_shell_line() {
|
||||||
|
let code = "curl https://example.com/x && echo done";
|
||||||
|
assert_spans(code, Language::Shell, Kind::Comment, &[]);
|
||||||
|
assert_spans(code, Language::Shell, Kind::Keyword, &["echo"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_url_inside_a_kotlin_string_stays_a_string() {
|
||||||
|
let code = "val url = \"https://example.com\"\nfun f() = 1";
|
||||||
|
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Kotlin,
|
||||||
|
Kind::String,
|
||||||
|
&["\"https://example.com\""],
|
||||||
|
);
|
||||||
|
assert_spans(code, Language::Kotlin, Kind::Keyword, &["val", "fun"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rust_attribute_is_metadata_and_the_struct_after_it_still_colours() {
|
||||||
|
let code = "#[derive(Debug)]\nstruct A { b: u8 }";
|
||||||
|
assert_spans(code, Language::Rust, Kind::Metadata, &["#[derive(Debug)]"]);
|
||||||
|
assert_spans(code, Language::Rust, Kind::Comment, &[]);
|
||||||
|
assert_spans(code, Language::Rust, Kind::Keyword, &["struct"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_inner_rust_attribute_closes_at_its_own_bracket() {
|
||||||
|
let code = "#![allow(dead_code)]\nfn f() {}";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Rust,
|
||||||
|
Kind::Metadata,
|
||||||
|
&["#![allow(dead_code)]"],
|
||||||
|
);
|
||||||
|
assert_spans(code, Language::Rust, Kind::Keyword, &["fn"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_c_preprocessor_line_is_metadata_rather_than_a_comment() {
|
||||||
|
let code = "#include <stdio.h>\nint main() { return 0; }";
|
||||||
|
assert_spans(code, Language::C, Kind::Metadata, &["#include <stdio.h>"]);
|
||||||
|
assert_spans(code, Language::C, Kind::Comment, &[]);
|
||||||
|
assert_spans(code, Language::C, Kind::Keyword, &["int", "return"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_kotlin_annotation_is_metadata() {
|
||||||
|
assert_spans(
|
||||||
|
"@Composable fun f() {}",
|
||||||
|
Language::Kotlin,
|
||||||
|
Kind::Metadata,
|
||||||
|
&["@Composable"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hash_inside_a_kotlin_string_is_not_a_comment() {
|
||||||
|
let code = "val c = \"#FF0000\"\nval d = 1";
|
||||||
|
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
|
||||||
|
assert_spans(code, Language::Kotlin, Kind::String, &["\"#FF0000\""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_apostrophe_inside_a_kotlin_string_does_not_open_one() {
|
||||||
|
let code = "val a = \"don't\"\nval b = \"x\"";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Kotlin,
|
||||||
|
Kind::String,
|
||||||
|
&["\"don't\"", "\"x\""],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rust_lifetime_does_not_open_a_string_but_a_character_literal_does() {
|
||||||
|
let code = "fn f<'a>(x: &'a str) { let c = 'x'; }";
|
||||||
|
assert_spans(code, Language::Rust, Kind::String, &["'x'"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_escaped_quote_is_inside_the_rust_character_literal() {
|
||||||
|
assert_spans("let c = '\\'';", Language::Rust, Kind::String, &["'\\''"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rust_raw_string_keeps_its_inner_quotes() {
|
||||||
|
let code = "let s = r#\"a \"quoted\" b\"#;";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Rust,
|
||||||
|
Kind::String,
|
||||||
|
&["r#\"a \"quoted\" b\"#"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_kotlin_triple_quoted_string_is_one_string() {
|
||||||
|
assert_spans(
|
||||||
|
"val s = \"\"\"a \"b\" c\"\"\"",
|
||||||
|
Language::Kotlin,
|
||||||
|
Kind::String,
|
||||||
|
&["\"\"\"a \"b\" c\"\"\""],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shell_single_quoted_string_takes_no_escapes() {
|
||||||
|
assert_spans("echo 'a\\' b", Language::Shell, Kind::String, &["'a\\'"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rust_and_kotlin_nest_block_comments() {
|
||||||
|
let code = "/* a /* b */ c */ x";
|
||||||
|
assert_spans(code, Language::Rust, Kind::Comment, &["/* a /* b */ c */"]);
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Kotlin,
|
||||||
|
Kind::Comment,
|
||||||
|
&["/* a /* b */ c */"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn c_ends_a_block_comment_at_the_first_close() {
|
||||||
|
assert_spans(
|
||||||
|
"/* a /* b */ c */ x",
|
||||||
|
Language::C,
|
||||||
|
Kind::Comment,
|
||||||
|
&["/* a /* b */"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shell_comment_starts_only_at_a_word_boundary() {
|
||||||
|
let code = "${#x} $# a#b # real";
|
||||||
|
assert_spans(code, Language::Shell, Kind::Comment, &["# real"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hash_anywhere_is_a_python_comment() {
|
||||||
|
assert_spans("x = 1 # note", Language::Python, Kind::Comment, &["# note"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_toml_table_header_is_metadata_and_a_hash_in_a_value_is_not_a_comment() {
|
||||||
|
let code = "[server]\ncolour = \"#FF0000\"\nport = 8080 # the real one";
|
||||||
|
assert_spans(code, Language::Toml, Kind::Metadata, &["[server]"]);
|
||||||
|
assert_spans(code, Language::Toml, Kind::String, &["\"#FF0000\""]);
|
||||||
|
assert_spans(code, Language::Toml, Kind::Comment, &["# the real one"]);
|
||||||
|
assert_spans(code, Language::Toml, Kind::Literal, &["8080"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ron_attribute_and_its_values_colour() {
|
||||||
|
let code = "#![enable(implicit_some)]\n(count: 3, on: true)";
|
||||||
|
assert_spans(
|
||||||
|
code,
|
||||||
|
Language::Ron,
|
||||||
|
Kind::Metadata,
|
||||||
|
&["#![enable(implicit_some)]"],
|
||||||
|
);
|
||||||
|
assert_spans(code, Language::Ron, Kind::Keyword, &["true"]);
|
||||||
|
assert_spans(code, Language::Ron, Kind::Literal, &["3"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_fence_language_is_none() {
|
||||||
|
assert_eq!(fence_language(Some("brainfuck")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_language_the_fence_table_knows_has_a_scanner() {
|
||||||
|
for language in Language::ALL {
|
||||||
|
spans_of("x", language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scanner must never panic and must never answer a span the code
|
||||||
|
/// does not contain: the library this replaced answered a reversed
|
||||||
|
/// range here, which crashed a card, and a fence still being written is
|
||||||
|
/// an unterminated string or comment on every keystroke.
|
||||||
|
#[test]
|
||||||
|
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
||||||
|
let nasty = [
|
||||||
|
"",
|
||||||
|
"'",
|
||||||
|
"\"",
|
||||||
|
"\"unterminated",
|
||||||
|
"/* unterminated",
|
||||||
|
"###",
|
||||||
|
"#",
|
||||||
|
"#.collect();
|
||||||
|
let spans = spans_of(code, language);
|
||||||
|
for s in &spans {
|
||||||
|
assert!(
|
||||||
|
s.start <= s.end && s.end <= chars.len(),
|
||||||
|
"{language:?} answered {s:?} for {code:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut sorted = spans.clone();
|
||||||
|
sorted.sort_by_key(|s| s.start);
|
||||||
|
assert_eq!(
|
||||||
|
spans, sorted,
|
||||||
|
"{language:?} answered spans out of order for {code:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! The app's pure logic, shared between the server and any Rust client --
|
||||||
|
//! see `docs/CLIENT_CORE.md` for what lives here and what does
|
||||||
|
//! not yet.
|
||||||
|
|
||||||
|
pub mod ansi;
|
||||||
|
pub mod api;
|
||||||
|
pub mod config;
|
||||||
|
pub mod event_stream;
|
||||||
|
pub mod highlight;
|
||||||
|
pub mod notifications;
|
||||||
|
pub mod sse;
|
||||||
|
pub mod transcript_cache;
|
||||||
|
pub mod transcript_fold;
|
||||||
|
|
||||||
|
pub use event_model::*;
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
|
||||||
|
//! places, never both" describes. Ported from the parsing half of
|
||||||
|
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
|
||||||
|
//! ([`crate::sse`]) and the wire shape ([`SessionNotification`],
|
||||||
|
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
||||||
|
//! `Notification`/`NotificationKind`).
|
||||||
|
//!
|
||||||
|
//! What is deliberately **not** here, because it is a decision rather than
|
||||||
|
//! logic: whether a given notification is shown at all (the session on
|
||||||
|
//! screen gets nothing), handed to the app as a banner, or posted to the
|
||||||
|
//! platform's own notification drawer. That three-way choice reads
|
||||||
|
//! process-wide state (what screen is open, whether the app is in front)
|
||||||
|
//! that has no meaning to a pure crate with no UI and no Android in it --
|
||||||
|
//! see `android-shell` for where it lives for this port.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::api::{ApiError, Transport};
|
||||||
|
use crate::sse::SseReader;
|
||||||
|
|
||||||
|
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
|
||||||
|
/// `Notification` field for field.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionNotification {
|
||||||
|
pub session_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub kind: NotificationKind,
|
||||||
|
/// Epoch seconds, so a phone that was asleep can say how long ago.
|
||||||
|
pub at: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized
|
||||||
|
/// the same way, so this deserializes the wire's `"awaitingInput"` /
|
||||||
|
/// `"finished"` directly rather than through a string match.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum NotificationKind {
|
||||||
|
AwaitingInput,
|
||||||
|
Finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationKind {
|
||||||
|
/// What a notification asks of the reader, in the words they see --
|
||||||
|
/// ported verbatim from `Notifications.kt`'s `attentionLine`. One
|
||||||
|
/// function because the same fact is shown in two places (the
|
||||||
|
/// platform's drawer and the app's own banner) and two mappings of one
|
||||||
|
/// word drift.
|
||||||
|
pub fn attention_line(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
NotificationKind::AwaitingInput => "Waiting for you",
|
||||||
|
NotificationKind::Finished => "Finished",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follows `/notifications`, calling `on_notification` for each frame until
|
||||||
|
/// the connection drops or the callback asks to stop (by returning
|
||||||
|
/// `false`). Reconnecting is the caller's job -- mirroring
|
||||||
|
/// `NotificationService.follow`'s retry loop, which is a platform policy
|
||||||
|
/// (how long to wait, whether to give up) rather than parsing logic.
|
||||||
|
pub fn follow_notifications(
|
||||||
|
transport: &dyn Transport,
|
||||||
|
mut on_notification: impl FnMut(SessionNotification) -> bool,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let body = transport.stream("/notifications")?;
|
||||||
|
let mut lines = BufReader::new(body).lines();
|
||||||
|
let mut reader = SseReader::new();
|
||||||
|
while let Some(line) = lines.next().transpose().map_err(|e| ApiError {
|
||||||
|
message: format!("Can't reach the server -- retrying. ({e})"),
|
||||||
|
status: None,
|
||||||
|
})? {
|
||||||
|
let Some(frame) = reader.feed_line(&line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if frame.data.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let notification: SessionNotification =
|
||||||
|
serde_json::from_str(&frame.data).map_err(|e| ApiError {
|
||||||
|
message: format!("The server sent a notification this build couldn't parse: {e}"),
|
||||||
|
status: None,
|
||||||
|
})?;
|
||||||
|
if !on_notification(notification) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::api::{Body, RawResponse};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
struct FixtureTransport {
|
||||||
|
body: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport for FixtureTransport {
|
||||||
|
fn request(
|
||||||
|
&self,
|
||||||
|
_method: &str,
|
||||||
|
_path: &str,
|
||||||
|
_body: Option<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError> {
|
||||||
|
unimplemented!("this fixture only serves a stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
|
||||||
|
Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_notification_frame_parses_both_kinds() {
|
||||||
|
let transport = FixtureTransport {
|
||||||
|
body: "data:{\"sessionId\":\"s1\",\"title\":\"fix the bug\",\"kind\":\"awaitingInput\",\"at\":1.0}\n\n\
|
||||||
|
data:{\"sessionId\":\"s2\",\"title\":\"add tests\",\"kind\":\"finished\",\"at\":2.0}\n\n",
|
||||||
|
};
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
follow_notifications(&transport, |n| {
|
||||||
|
seen.push((n.session_id, n.kind));
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
seen,
|
||||||
|
vec![
|
||||||
|
("s1".to_string(), NotificationKind::AwaitingInput),
|
||||||
|
("s2".to_string(), NotificationKind::Finished),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_caller_can_stop_early() {
|
||||||
|
let transport = FixtureTransport {
|
||||||
|
body: "data:{\"sessionId\":\"s1\",\"title\":\"a\",\"kind\":\"finished\",\"at\":1.0}\n\n\
|
||||||
|
data:{\"sessionId\":\"s2\",\"title\":\"b\",\"kind\":\"finished\",\"at\":2.0}\n\n",
|
||||||
|
};
|
||||||
|
let mut count = 0;
|
||||||
|
follow_notifications(&transport, |_| {
|
||||||
|
count += 1;
|
||||||
|
count < 1
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attention_line_matches_the_kotlin_original() {
|
||||||
|
assert_eq!(
|
||||||
|
NotificationKind::AwaitingInput.attention_line(),
|
||||||
|
"Waiting for you"
|
||||||
|
);
|
||||||
|
assert_eq!(NotificationKind::Finished.attention_line(), "Finished");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! Server-sent-events framing, ported from `app/.../Sse.kt`: `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.
|
||||||
|
//!
|
||||||
|
//! Pure and line-at-a-time, unlike the Kotlin original which also owned the
|
||||||
|
//! socket: `server/routes.rs`'s SSE bodies are one event per line, so a
|
||||||
|
//! caller here feeds lines from wherever they came from (a real connection,
|
||||||
|
//! a test fixture) and gets frames back with no I/O of its own -- which is
|
||||||
|
//! what lets this be tested with no server, per RUST.md's "pure logic
|
||||||
|
//! first" for this crate.
|
||||||
|
|
||||||
|
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Frame {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulates lines into [`Frame`]s. One instance per connection --
|
||||||
|
/// `feed_line` is called for every line the transport reads (with line
|
||||||
|
/// endings already stripped), and answers a frame when a blank line closes
|
||||||
|
/// one.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct SseReader {
|
||||||
|
data: String,
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SseReader {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feeds one line (no trailing `\n`). Answers the frame this line
|
||||||
|
/// completed, if any.
|
||||||
|
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
|
||||||
|
if line.is_empty() {
|
||||||
|
if self.name.is_some() || !self.data.is_empty() {
|
||||||
|
let frame = Frame {
|
||||||
|
name: self.name.take(),
|
||||||
|
data: std::mem::take(&mut self.data),
|
||||||
|
};
|
||||||
|
return Some(frame);
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(rest) = line.strip_prefix("data:") {
|
||||||
|
self.data.push_str(rest.trim());
|
||||||
|
} else if let Some(rest) = line.strip_prefix("event:") {
|
||||||
|
self.name = Some(rest.trim().to_string());
|
||||||
|
}
|
||||||
|
// `id:`, comments -- nothing to do.
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn frames(lines: &[&str]) -> Vec<Frame> {
|
||||||
|
let mut reader = SseReader::new();
|
||||||
|
lines.iter().filter_map(|l| reader.feed_line(l)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_data_only_frame_has_no_name() {
|
||||||
|
assert_eq!(
|
||||||
|
frames(&["data:hello", ""]),
|
||||||
|
vec![Frame {
|
||||||
|
name: None,
|
||||||
|
data: "hello".to_string()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_named_frame_with_no_payload_still_completes() {
|
||||||
|
assert_eq!(
|
||||||
|
frames(&["event:reset", ""]),
|
||||||
|
vec![Frame {
|
||||||
|
name: Some("reset".to_string()),
|
||||||
|
data: String::new()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blank_line_with_nothing_pending_yields_no_frame() {
|
||||||
|
assert_eq!(frames(&[""]), vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_comment_and_an_id_line_are_ignored() {
|
||||||
|
assert_eq!(
|
||||||
|
frames(&[":keepalive", "id:5", "data:hi", ""]),
|
||||||
|
vec![Frame {
|
||||||
|
name: None,
|
||||||
|
data: "hi".to_string()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_frames_in_a_row_are_both_reported() {
|
||||||
|
assert_eq!(
|
||||||
|
frames(&["data:one", "", "data:two", ""]),
|
||||||
|
vec![
|
||||||
|
Frame {
|
||||||
|
name: None,
|
||||||
|
data: "one".to_string()
|
||||||
|
},
|
||||||
|
Frame {
|
||||||
|
name: None,
|
||||||
|
data: "two".to_string()
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,958 @@
|
|||||||
|
//! What the transcript renders: the event stream folded into displayable
|
||||||
|
//! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s
|
||||||
|
//! non-Compose half (`TranscriptRow`, `groupToolRuns`).
|
||||||
|
//!
|
||||||
|
//! 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 the transcript cache are all the same
|
||||||
|
//! events through the same fold.
|
||||||
|
//!
|
||||||
|
//! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into
|
||||||
|
//! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer
|
||||||
|
//! exists to bound how much a lazy list composes per frame, which is a
|
||||||
|
//! fact about the UI framework drawing it, not about the transcript. See
|
||||||
|
//! `CLIENT_CORE.md`.
|
||||||
|
//!
|
||||||
|
//! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate
|
||||||
|
//! deserializes straight into [`event_model::Event`], which has no
|
||||||
|
//! `Unknown` catch-all -- an event type this build does not recognise
|
||||||
|
//! fails to parse rather than degrading to a placeholder row. Closing that
|
||||||
|
//! gap means giving `event_model::Event` its own forward-compatible
|
||||||
|
//! variant, which is a shared-model decision for both sides of the wire
|
||||||
|
//! and is deliberately left for whoever picks this up next (see
|
||||||
|
//! `CLIENT_CORE.md`).
|
||||||
|
|
||||||
|
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
|
||||||
|
|
||||||
|
/// A question this build has already asked the reader about, with what was
|
||||||
|
/// answered so far -- distinct from [`QuestionOption`], which is what could
|
||||||
|
/// be chosen.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct QuestionCard {
|
||||||
|
pub seq: u64,
|
||||||
|
pub id: String,
|
||||||
|
pub prompt: String,
|
||||||
|
pub header: Option<String>,
|
||||||
|
pub options: Vec<QuestionOption>,
|
||||||
|
pub multi_select: bool,
|
||||||
|
pub answers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tool call cannot be recognised as `AskUserQuestion` from a bare
|
||||||
|
/// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption
|
||||||
|
/// logic name it explicitly.
|
||||||
|
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
|
||||||
|
|
||||||
|
/// This item's identity in the list: a `Seq` for everything with no
|
||||||
|
/// identity of its own, `RunId` for a tool call (which keeps one across
|
||||||
|
/// however many calls join or leave its run), matching `TranscriptItem.key`
|
||||||
|
/// in the Kotlin original.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum ItemKey {
|
||||||
|
Seq(u64),
|
||||||
|
RunId(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the transcript, folded from [`Event`]s. See each variant's
|
||||||
|
/// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this
|
||||||
|
/// doc only says what changed in translation.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum TranscriptItem {
|
||||||
|
UserMsg {
|
||||||
|
seq: u64,
|
||||||
|
text: String,
|
||||||
|
attachments: Vec<String>,
|
||||||
|
},
|
||||||
|
AssistantMsg {
|
||||||
|
seq: u64,
|
||||||
|
text: String,
|
||||||
|
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
|
||||||
|
/// Kotlin doc for why the split it licenses matters.
|
||||||
|
settled: bool,
|
||||||
|
},
|
||||||
|
ToolRun {
|
||||||
|
seq: u64,
|
||||||
|
id: String,
|
||||||
|
run_id: String,
|
||||||
|
tool: String,
|
||||||
|
input: String,
|
||||||
|
output: String,
|
||||||
|
done: bool,
|
||||||
|
asks: Vec<QuestionCard>,
|
||||||
|
images: Vec<String>,
|
||||||
|
},
|
||||||
|
QuestionCard(QuestionCard),
|
||||||
|
ErrorMsg {
|
||||||
|
seq: u64,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
ImageItem {
|
||||||
|
seq: u64,
|
||||||
|
r#ref: String,
|
||||||
|
},
|
||||||
|
/// A message from another agent. `arrived` is this row's own identity
|
||||||
|
/// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which
|
||||||
|
/// [`place_peer_note`] may set to the turn's opening seq instead.
|
||||||
|
PeerNote {
|
||||||
|
seq: u64,
|
||||||
|
from: String,
|
||||||
|
text: String,
|
||||||
|
arrived: u64,
|
||||||
|
},
|
||||||
|
CommandRow {
|
||||||
|
seq: u64,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// Placeholder for an event kind this build could not fold -- see the
|
||||||
|
/// module doc's "known gap".
|
||||||
|
Note {
|
||||||
|
seq: u64,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
ClearedNote {
|
||||||
|
seq: u64,
|
||||||
|
},
|
||||||
|
/// The account's usage limit stopped the turn; `resets_at` is epoch
|
||||||
|
/// seconds when the dialect said when it lifts (`LimitNote` in
|
||||||
|
/// `TranscriptItems.kt`).
|
||||||
|
LimitNote {
|
||||||
|
seq: u64,
|
||||||
|
resets_at: Option<f64>,
|
||||||
|
},
|
||||||
|
CompactedNote {
|
||||||
|
seq: u64,
|
||||||
|
pre_tokens: Option<u64>,
|
||||||
|
post_tokens: Option<u64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscriptItem {
|
||||||
|
pub fn seq(&self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Self::UserMsg { seq, .. }
|
||||||
|
| Self::AssistantMsg { seq, .. }
|
||||||
|
| Self::ToolRun { seq, .. }
|
||||||
|
| Self::ErrorMsg { seq, .. }
|
||||||
|
| Self::ImageItem { seq, .. }
|
||||||
|
| Self::PeerNote { seq, .. }
|
||||||
|
| Self::CommandRow { seq, .. }
|
||||||
|
| Self::Note { seq, .. }
|
||||||
|
| Self::ClearedNote { seq }
|
||||||
|
| Self::LimitNote { seq, .. }
|
||||||
|
| Self::CompactedNote { seq, .. } => *seq,
|
||||||
|
Self::QuestionCard(card) => card.seq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key(&self) -> ItemKey {
|
||||||
|
match self {
|
||||||
|
Self::ToolRun { run_id, .. } => ItemKey::RunId(run_id.clone()),
|
||||||
|
Self::PeerNote { arrived, .. } => ItemKey::Seq(*arrived),
|
||||||
|
other => ItemKey::Seq(other.seq()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_tool_run(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::ToolRun { id, .. } => Some(id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The run a call joins: the one it lands next to, or a new one named
|
||||||
|
/// after itself. See the Kotlin `runIdFor`'s doc for why the name, once
|
||||||
|
/// picked, never changes.
|
||||||
|
fn run_id_for(items: &[TranscriptItem], id: &str, tool: &str) -> String {
|
||||||
|
let Some(TranscriptItem::ToolRun {
|
||||||
|
run_id,
|
||||||
|
tool: previous_tool,
|
||||||
|
..
|
||||||
|
}) = items.last()
|
||||||
|
else {
|
||||||
|
return id.to_string();
|
||||||
|
};
|
||||||
|
if tool == ASK_USER_QUESTION || previous_tool == ASK_USER_QUESTION {
|
||||||
|
id.to_string()
|
||||||
|
} else {
|
||||||
|
run_id.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_tool(
|
||||||
|
items: &[TranscriptItem],
|
||||||
|
id: &str,
|
||||||
|
change: impl Fn(&mut TranscriptItem),
|
||||||
|
) -> Vec<TranscriptItem> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|mut item| {
|
||||||
|
if item.as_tool_run() == Some(id) {
|
||||||
|
change(&mut item);
|
||||||
|
}
|
||||||
|
item
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a status means the session is still doing something, mirroring
|
||||||
|
/// `sessionWorking` in `Events.kt`.
|
||||||
|
pub fn session_working(status: SessionStatus) -> bool {
|
||||||
|
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A status saying the session stopped working is the moment its newest
|
||||||
|
/// reply is finished.
|
||||||
|
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
|
||||||
|
if session_working(status) {
|
||||||
|
return items.to_vec();
|
||||||
|
}
|
||||||
|
let Some(TranscriptItem::AssistantMsg { settled: false, .. }) = items.last() else {
|
||||||
|
return items.to_vec();
|
||||||
|
};
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
if let Some(TranscriptItem::AssistantMsg { settled, .. }) = items.last_mut() {
|
||||||
|
*settled = true;
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A peer message goes above the turn it started, not where it happened to
|
||||||
|
/// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning;
|
||||||
|
/// `turn_start` is `Event::PeerMessage`'s own field of that name.
|
||||||
|
fn place_peer_note(
|
||||||
|
items: &[TranscriptItem],
|
||||||
|
seq: u64,
|
||||||
|
from: &str,
|
||||||
|
text: &str,
|
||||||
|
turn_start: Option<u64>,
|
||||||
|
) -> Vec<TranscriptItem> {
|
||||||
|
let Some(at) = turn_start else {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::PeerNote {
|
||||||
|
seq,
|
||||||
|
from: from.to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
arrived: seq,
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
let note = TranscriptItem::PeerNote {
|
||||||
|
seq: at,
|
||||||
|
from: from.to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
arrived: seq,
|
||||||
|
};
|
||||||
|
let Some(index) = items.iter().position(|i| i.seq() > at) else {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(note);
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
let behind = match index.checked_sub(1).and_then(|i| items.get(i)) {
|
||||||
|
Some(TranscriptItem::ToolRun { run_id, .. }) => Some(run_id.clone()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let mut out = items[..index].to_vec();
|
||||||
|
out.push(note);
|
||||||
|
out.extend(split_run(&items[index..], behind.as_deref()));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calls the note now sits in front of, renamed if they were sharing a
|
||||||
|
/// run with the calls behind it. See the Kotlin `splitRun`'s doc.
|
||||||
|
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
|
||||||
|
let Some(TranscriptItem::ToolRun {
|
||||||
|
run_id: first_run_id,
|
||||||
|
id: first_id,
|
||||||
|
..
|
||||||
|
}) = tail.first()
|
||||||
|
else {
|
||||||
|
return tail.to_vec();
|
||||||
|
};
|
||||||
|
let Some(behind) = behind else {
|
||||||
|
return tail.to_vec();
|
||||||
|
};
|
||||||
|
if first_run_id != behind {
|
||||||
|
return tail.to_vec();
|
||||||
|
}
|
||||||
|
let run_len = tail
|
||||||
|
.iter()
|
||||||
|
.take_while(|i| matches!(i, TranscriptItem::ToolRun { run_id, .. } if run_id == behind))
|
||||||
|
.count();
|
||||||
|
let mut out: Vec<TranscriptItem> = tail[..run_len]
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|mut item| {
|
||||||
|
if let TranscriptItem::ToolRun { run_id, .. } = &mut item {
|
||||||
|
*run_id = first_id.clone();
|
||||||
|
}
|
||||||
|
item
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.extend(tail[run_len..].iter().cloned());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
||||||
|
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
||||||
|
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
||||||
|
/// at the parse layer).
|
||||||
|
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
|
||||||
|
let seq = entry.seq;
|
||||||
|
match &entry.event {
|
||||||
|
Event::UserMessage {
|
||||||
|
text, attachments, ..
|
||||||
|
} => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.clone(),
|
||||||
|
attachments: attachments.clone(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
// `MessageTaken` is folded into `UserMessage` by the manager before
|
||||||
|
// it reaches a phone (see `PLAN.md`); if one arrives here anyway
|
||||||
|
// (a raw transcript line, say), it reads the same way.
|
||||||
|
Event::MessageTaken {
|
||||||
|
text, attachments, ..
|
||||||
|
} => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.clone(),
|
||||||
|
attachments: attachments.clone(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
Event::AssistantText { delta } => {
|
||||||
|
// 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 every frame.
|
||||||
|
// "A message growing again is not finished" -- whatever a
|
||||||
|
// status said in between -- is why this always clears
|
||||||
|
// `settled` rather than preserving it.
|
||||||
|
if let Some(TranscriptItem::AssistantMsg {
|
||||||
|
seq: first_seq,
|
||||||
|
text,
|
||||||
|
..
|
||||||
|
}) = items.last()
|
||||||
|
{
|
||||||
|
let first_seq = *first_seq;
|
||||||
|
let text = format!("{text}{delta}");
|
||||||
|
let mut items = items[..items.len() - 1].to_vec();
|
||||||
|
items.push(TranscriptItem::AssistantMsg {
|
||||||
|
seq: first_seq,
|
||||||
|
text,
|
||||||
|
settled: false,
|
||||||
|
});
|
||||||
|
items
|
||||||
|
} else {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::AssistantMsg {
|
||||||
|
seq,
|
||||||
|
text: delta.clone(),
|
||||||
|
settled: false,
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ToolStart { id, tool, input } => {
|
||||||
|
let run_id = run_id_for(items, id, tool);
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id: id.clone(),
|
||||||
|
run_id,
|
||||||
|
tool: tool.clone(),
|
||||||
|
input: input.to_string(),
|
||||||
|
output: String::new(),
|
||||||
|
done: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
Event::ToolUpdate { id, output } => update_tool(items, id, |item| {
|
||||||
|
if let TranscriptItem::ToolRun { output: out, .. } = item {
|
||||||
|
*out = output.clone();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Event::ToolEnd { id, output } => {
|
||||||
|
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
||||||
|
update_tool(items, id, |item| {
|
||||||
|
if let TranscriptItem::ToolRun {
|
||||||
|
output: out, done, ..
|
||||||
|
} = item
|
||||||
|
{
|
||||||
|
*out = output.clone();
|
||||||
|
*done = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let run_id = run_id_for(items, id, "tool");
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id: id.clone(),
|
||||||
|
run_id,
|
||||||
|
tool: "tool".to_string(),
|
||||||
|
input: String::new(),
|
||||||
|
output: output.clone(),
|
||||||
|
done: true,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Question {
|
||||||
|
id,
|
||||||
|
prompt,
|
||||||
|
header,
|
||||||
|
options,
|
||||||
|
multi_select,
|
||||||
|
about,
|
||||||
|
} => {
|
||||||
|
let card = QuestionCard {
|
||||||
|
seq,
|
||||||
|
id: id.clone(),
|
||||||
|
prompt: prompt.clone(),
|
||||||
|
header: header.clone(),
|
||||||
|
options: options.clone(),
|
||||||
|
multi_select: *multi_select,
|
||||||
|
answers: Vec::new(),
|
||||||
|
};
|
||||||
|
let about_tool = about
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|about| items.iter().any(|i| i.as_tool_run() == Some(about)));
|
||||||
|
if about_tool {
|
||||||
|
let about = about.clone().unwrap();
|
||||||
|
update_tool(items, &about, move |item| {
|
||||||
|
if let TranscriptItem::ToolRun { asks, .. } = item {
|
||||||
|
asks.push(card.clone());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::QuestionCard(card));
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Answered { id, answers } => items
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|item| match item {
|
||||||
|
TranscriptItem::QuestionCard(mut card) if &card.id == id => {
|
||||||
|
card.answers = answers.clone();
|
||||||
|
TranscriptItem::QuestionCard(card)
|
||||||
|
}
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
mut asks,
|
||||||
|
seq,
|
||||||
|
id: tid,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
done,
|
||||||
|
images,
|
||||||
|
} if asks.iter().any(|a| &a.id == id) => {
|
||||||
|
for ask in asks.iter_mut() {
|
||||||
|
if &ask.id == id {
|
||||||
|
ask.answers = answers.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id: tid,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
done,
|
||||||
|
asks,
|
||||||
|
images,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
Event::PeerMessage {
|
||||||
|
from,
|
||||||
|
text,
|
||||||
|
turn_start,
|
||||||
|
} => place_peer_note(items, seq, from, text, *turn_start),
|
||||||
|
Event::CommandSent { text, .. } => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::CommandRow {
|
||||||
|
seq,
|
||||||
|
text: text.clone(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
// Screen-level state, not transcript rows.
|
||||||
|
Event::CommandQueued { .. }
|
||||||
|
| Event::MessageQueued { .. }
|
||||||
|
| Event::MessageDropped { .. }
|
||||||
|
| Event::Settings { .. }
|
||||||
|
| Event::UsageDelta { .. } => items.to_vec(),
|
||||||
|
Event::Status { state } => settle_reply(items, *state),
|
||||||
|
Event::Error { message } => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::ErrorMsg {
|
||||||
|
seq,
|
||||||
|
message: message.clone(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
Event::Image { image, about } => {
|
||||||
|
let about_tool = about
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|about| items.iter().any(|i| i.as_tool_run() == Some(about)));
|
||||||
|
if about_tool {
|
||||||
|
let about = about.clone().unwrap();
|
||||||
|
let image = image.clone();
|
||||||
|
update_tool(items, &about, move |item| {
|
||||||
|
if let TranscriptItem::ToolRun { images, .. } = item {
|
||||||
|
images.push(image.clone());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::ImageItem {
|
||||||
|
seq,
|
||||||
|
r#ref: image.clone(),
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Cleared => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::ClearedNote { seq });
|
||||||
|
items
|
||||||
|
}
|
||||||
|
Event::LimitReached { resets_at } => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::LimitNote {
|
||||||
|
seq,
|
||||||
|
resets_at: *resets_at,
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
Event::Compacted {
|
||||||
|
pre_tokens,
|
||||||
|
post_tokens,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let mut items = items.to_vec();
|
||||||
|
items.push(TranscriptItem::CompactedNote {
|
||||||
|
seq,
|
||||||
|
pre_tokens: *pre_tokens,
|
||||||
|
post_tokens: *post_tokens,
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
||||||
|
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
||||||
|
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
||||||
|
/// of this crate.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum TranscriptRow {
|
||||||
|
Single(TranscriptItem),
|
||||||
|
/// Two or more calls with nothing between them.
|
||||||
|
Tools(Vec<TranscriptItem>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscriptRow {
|
||||||
|
pub fn key(&self) -> ItemKey {
|
||||||
|
match self {
|
||||||
|
Self::Single(item) => item.key(),
|
||||||
|
Self::Tools(calls) => calls[0].key(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_seq(&self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Self::Single(item) => item.seq(),
|
||||||
|
Self::Tools(calls) => calls[0].seq(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs of adjacent tool calls become one row; everything else passes
|
||||||
|
/// through. See the Kotlin `groupRuns`'s doc for why grouping is by the
|
||||||
|
/// run each call names rather than by adjacency worked out here.
|
||||||
|
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut run: Vec<TranscriptItem> = Vec::new();
|
||||||
|
|
||||||
|
fn run_id_of(item: &TranscriptItem) -> Option<&str> {
|
||||||
|
match item {
|
||||||
|
TranscriptItem::ToolRun { run_id, .. } => Some(run_id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let flush = |run: &mut Vec<TranscriptItem>, rows: &mut Vec<TranscriptRow>| match run.len() {
|
||||||
|
0 => {}
|
||||||
|
1 => rows.push(TranscriptRow::Single(run.drain(..).next().unwrap())),
|
||||||
|
_ => rows.push(TranscriptRow::Tools(std::mem::take(run))),
|
||||||
|
};
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
let joins = matches!(item, TranscriptItem::ToolRun { .. })
|
||||||
|
&& (run.is_empty() || run_id_of(&run[0]) == run_id_of(item));
|
||||||
|
if joins {
|
||||||
|
run.push(item.clone());
|
||||||
|
} else {
|
||||||
|
flush(&mut run, &mut rows);
|
||||||
|
if matches!(item, TranscriptItem::ToolRun { .. }) {
|
||||||
|
run.push(item.clone());
|
||||||
|
} else {
|
||||||
|
rows.push(TranscriptRow::Single(item.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(&mut run, &mut rows);
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s
|
||||||
|
/// `Vec<Value>`) into the flat item list this module works over. A line
|
||||||
|
/// this build can't parse fails the whole page rather than being skipped --
|
||||||
|
/// CODE_RULES's "an enumeration must be able to say 'it broke'" -- since
|
||||||
|
/// silently dropping one event could hide, say, a user message that then
|
||||||
|
/// looks like it was never sent. Moved here from `desktop-app`'s `app.rs`
|
||||||
|
/// (RUST.md's E4) when the Android transcript client (I5) needed the same
|
||||||
|
/// fold: "write the logic once" applies to any caller embedding
|
||||||
|
/// `transcript-ui` against a live server, not just the first one.
|
||||||
|
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for value in values {
|
||||||
|
let event: SeqEvent = serde_json::from_value(value.clone()).map_err(|e| {
|
||||||
|
format!("the server sent a transcript line this build couldn't parse: {e}")
|
||||||
|
})?;
|
||||||
|
items = fold_event(&items, &event);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire `seq` a raw transcript line carries -- the live-stream resume
|
||||||
|
/// cursor after loading a page must be this, not a folded item's `seq()`.
|
||||||
|
/// A folded `AssistantMsg` keeps the seq of the *first* delta it
|
||||||
|
/// accumulated (`fold_event`'s own doc), so resuming from that seq would
|
||||||
|
/// re-deliver every delta already folded into it, duplicating the tail of
|
||||||
|
/// a reply that was mid-stream when the page was fetched -- found via a
|
||||||
|
/// real screenshot in E4 (RUST.md), where the assistant's line doubled.
|
||||||
|
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
|
||||||
|
value.get("seq")?.as_u64()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn event(seq: u64, event: Event) -> SeqEvent {
|
||||||
|
SeqEvent {
|
||||||
|
seq,
|
||||||
|
ts: 1.0,
|
||||||
|
event,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_all(events: &[SeqEvent]) -> Vec<TranscriptItem> {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.fold(Vec::new(), |items, e| fold_event(&items, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assistant_deltas_accumulate_into_one_message() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::AssistantText {
|
||||||
|
delta: "hel".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::AssistantText {
|
||||||
|
delta: "lo".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![TranscriptItem::AssistantMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hello".to_string(),
|
||||||
|
settled: false
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_status_that_stopped_working_settles_the_newest_reply() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::AssistantText {
|
||||||
|
delta: "hi".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::Idle,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![TranscriptItem::AssistantMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
settled: true
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_working_status_does_not_settle_anything() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::AssistantText {
|
||||||
|
delta: "hi".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::Running,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![TranscriptItem::AssistantMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
settled: false
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjacent_tool_calls_group_and_a_lone_one_does_not() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: "a".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: "b".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let rows = group_tool_runs(&items);
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert!(matches!(&rows[0], TranscriptRow::Tools(calls) if calls.len() == 2));
|
||||||
|
|
||||||
|
let solo = fold_all(&[event(
|
||||||
|
1,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: "a".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
)]);
|
||||||
|
let rows = group_tool_runs(&solo);
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
&rows[0],
|
||||||
|
TranscriptRow::Single(TranscriptItem::ToolRun { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tool_end_with_no_matching_start_still_draws_a_row() {
|
||||||
|
let items = fold_all(&[event(
|
||||||
|
5,
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: "x".to_string(),
|
||||||
|
output: "done".to_string(),
|
||||||
|
},
|
||||||
|
)]);
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![TranscriptItem::ToolRun {
|
||||||
|
seq: 5,
|
||||||
|
id: "x".to_string(),
|
||||||
|
run_id: "x".to_string(),
|
||||||
|
tool: "tool".to_string(),
|
||||||
|
input: String::new(),
|
||||||
|
output: "done".to_string(),
|
||||||
|
done: true,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_question_about_a_tool_call_attaches_to_its_row_rather_than_drawing_its_own() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: "a".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::Question {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
prompt: "run it?".to_string(),
|
||||||
|
header: None,
|
||||||
|
options: vec![QuestionOption::plain("yes"), QuestionOption::plain("no")],
|
||||||
|
multi_select: false,
|
||||||
|
about: Some("a".to_string()),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
match &items[0] {
|
||||||
|
TranscriptItem::ToolRun { asks, .. } => assert_eq!(asks.len(), 1),
|
||||||
|
other => panic!("expected a ToolRun, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn answering_resolves_a_bare_question_card() {
|
||||||
|
let items = fold_all(&[
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::Question {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
prompt: "pick one".to_string(),
|
||||||
|
header: None,
|
||||||
|
options: vec![QuestionOption::plain("a")],
|
||||||
|
multi_select: false,
|
||||||
|
about: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::Answered {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
answers: vec!["a".to_string()],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
match &items[0] {
|
||||||
|
TranscriptItem::QuestionCard(card) => assert_eq!(card.answers, vec!["a".to_string()]),
|
||||||
|
other => panic!("expected a QuestionCard, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line(seq: u64, json: serde_json::Value) -> serde_json::Value {
|
||||||
|
let mut obj = json;
|
||||||
|
obj["seq"] = serde_json::json!(seq);
|
||||||
|
obj["ts"] = serde_json::json!(1.0);
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The regression for a bug a real `run-headless.sh` screenshot found
|
||||||
|
/// in `desktop-app` (E4, RUST.md): resuming the live stream from the
|
||||||
|
/// last *item's* seq re-delivers the deltas already folded into a
|
||||||
|
/// still-open assistant message, doubling its tail. `raw_seq` of the
|
||||||
|
/// last wire line must be the true high-water mark instead, which for a
|
||||||
|
/// run of deltas is higher than every item's own `seq()`.
|
||||||
|
#[test]
|
||||||
|
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
|
||||||
|
let values = vec![
|
||||||
|
line(1, serde_json::json!({"type": "userMessage", "text": "hi"})),
|
||||||
|
line(
|
||||||
|
2,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "a"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
3,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "b"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
4,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "c"}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let after = raw_seq(values.last().unwrap()).unwrap();
|
||||||
|
assert_eq!(after, 4);
|
||||||
|
|
||||||
|
let items = fold_page(&values).unwrap();
|
||||||
|
let assistant_seq = items
|
||||||
|
.iter()
|
||||||
|
.find(|i| matches!(i, TranscriptItem::AssistantMsg { .. }))
|
||||||
|
.unwrap()
|
||||||
|
.seq();
|
||||||
|
assert_eq!(assistant_seq, 2);
|
||||||
|
assert_ne!(
|
||||||
|
after, assistant_seq,
|
||||||
|
"the fixed bug: these must differ here"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_page_folds_into_one_settled_assistant_message() {
|
||||||
|
let values = vec![
|
||||||
|
line(1, serde_json::json!({"type": "userMessage", "text": "hi"})),
|
||||||
|
line(
|
||||||
|
2,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "hel"}),
|
||||||
|
),
|
||||||
|
line(
|
||||||
|
3,
|
||||||
|
serde_json::json!({"type": "assistantText", "delta": "lo"}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let items = fold_page(&values).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
items,
|
||||||
|
vec![
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "hello".to_string(),
|
||||||
|
settled: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unparseable_line_fails_the_whole_page() {
|
||||||
|
let values = vec![serde_json::json!({"seq": 1, "ts": 1.0, "type": "not-a-real-type"})];
|
||||||
|
let err = fold_page(&values).unwrap_err();
|
||||||
|
assert!(err.contains("couldn't parse"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# client-core
|
||||||
|
|
||||||
|
`client-core/` is the app's pure logic held once instead of twice, per
|
||||||
|
RUST.md's recommendation item 1. It is a plain Rust library crate with no UI
|
||||||
|
framework dependency of any kind, so it can outlive whichever one the app
|
||||||
|
ends up drawing with (Masonry, iris, or something else -- see RUST.md).
|
||||||
|
`event-model/` is its sibling: the wire shape both this crate and `server/`
|
||||||
|
share, extracted from `server/src/session/driver.rs` and
|
||||||
|
`session/transcript.rs` on 2026-09-04.
|
||||||
|
|
||||||
|
Neither crate is wired into anything yet. `server/` re-exports `event-model`
|
||||||
|
so its own behaviour is unchanged (`./run-tests.sh` covers it); `client-core`
|
||||||
|
has no caller -- it exists for whichever experiment in RUST.md picks it up
|
||||||
|
next (a Masonry or iris transcript screen, most likely).
|
||||||
|
|
||||||
|
## What's here, and what Kotlin file it replaces
|
||||||
|
|
||||||
|
| `client-core/src/…` | Kotlin original | Status |
|
||||||
|
|------------------------------------------|-------------------------------------------|--------|
|
||||||
|
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
|
||||||
|
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
|
||||||
|
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
|
||||||
|
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
|
||||||
|
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
|
||||||
|
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
|
||||||
|
| `api.rs` | `Api.kt` | Partial -- see below |
|
||||||
|
| `event_stream.rs` | `EventStream.kt` | Done |
|
||||||
|
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
|
||||||
|
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
||||||
|
| *(not started)* | `TranscriptSource.kt` | Not started |
|
||||||
|
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
||||||
|
|
||||||
|
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
||||||
|
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
||||||
|
cases ported alongside it, plus new tests for the pieces that had none
|
||||||
|
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
|
||||||
|
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
|
||||||
|
types carry no logic of their own to test -- `server/`'s own tests exercise
|
||||||
|
them via `session::transcript`'s round-trip coverage).
|
||||||
|
|
||||||
|
## Correspondence notes worth knowing before touching either side
|
||||||
|
|
||||||
|
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
|
||||||
|
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
|
||||||
|
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
|
||||||
|
non-overlapping spans. Whatever UI framework ends up consuming this
|
||||||
|
crate maps `Style` onto its own text-styling type; nothing here should
|
||||||
|
change to accommodate a particular one.
|
||||||
|
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
|
||||||
|
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
|
||||||
|
strings. `highlight::span_text` turns a `Span` back into text for a
|
||||||
|
caller working the same way; a caller that wants byte offsets into a
|
||||||
|
`&str` has to convert.
|
||||||
|
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
|
||||||
|
translation bug while it was being written: an early draft let a
|
||||||
|
*damaged* chunk (one file unreadable, discard just this session) and a
|
||||||
|
genuine I/O failure (disk gone, disable the whole cache) both surface as
|
||||||
|
the same `Err` from one closure, which would have disabled every
|
||||||
|
session's cache over a single corrupt chunk. Fixed by checking a
|
||||||
|
thread-local "was this damage" flag before deciding which failure mode
|
||||||
|
it was -- see the comment on `guard` and the commit message for
|
||||||
|
`transcript_cache.rs`.
|
||||||
|
|
||||||
|
## What `api.rs` covers, and what it does not yet
|
||||||
|
|
||||||
|
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
|
||||||
|
`ApiClient` and `event_stream::follow_session_events` are tested with a
|
||||||
|
fake transport and no server). `UreqTransport` is the only real
|
||||||
|
implementation, backed by `ureq` -- see its Cargo.toml comment for why
|
||||||
|
(blocking, already a project dependency, no extra TLS crate needed since
|
||||||
|
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
|
||||||
|
|
||||||
|
Covered: session list/read, message send, unqueue, answer, interrupt,
|
||||||
|
stop, start, rename, cwd, model, permission-mode, notify, command,
|
||||||
|
compact, delete, and one transcript page.
|
||||||
|
|
||||||
|
**Not covered, and each is real work rather than a stub to fill in:**
|
||||||
|
setups (`/setups*`, machine and provider discovery), the file explorer
|
||||||
|
(`/setups/{id}/dir|file`), usage (`/usage`), models
|
||||||
|
(`/models*`, HuggingFace browsing and downloads), attachments
|
||||||
|
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
|
||||||
|
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
|
||||||
|
the full table to work from when one of these is next.
|
||||||
|
|
||||||
|
## What `transcript_fold.rs` covers, and what it does not yet
|
||||||
|
|
||||||
|
`fold_event` covers every `Event` variant server/ can produce today,
|
||||||
|
including tool-call/question/image attachment and peer-message placement.
|
||||||
|
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
||||||
|
|
||||||
|
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
|
||||||
|
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
|
||||||
|
merges a tool call split across two fetched pages and re-merges a run a
|
||||||
|
boundary cut through. This matters the moment paging backward through
|
||||||
|
history is exercised; it is deliberately left rather than rushed, since
|
||||||
|
it is exactly the kind of boundary logic this project's own "things that
|
||||||
|
have bitten" section warns reads fine and is wrong at the edges.
|
||||||
|
|
||||||
|
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
||||||
|
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
||||||
|
A server newer than this build that adds an event type will fail to parse
|
||||||
|
that line rather than degrading to a placeholder row. Closing this means
|
||||||
|
deciding how `event_model` itself represents "a shape I don't recognise"
|
||||||
|
-- a shared-model decision affecting `server/` too, not a `client-core`-only
|
||||||
|
fix, so it is recorded here rather than silently worked around.
|
||||||
|
|
||||||
|
## `config.rs`: `EnrolledServer`
|
||||||
|
|
||||||
|
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
|
||||||
|
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
|
||||||
|
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
|
||||||
|
parses on the phone -- so any Rust client enrols from the same text a
|
||||||
|
phone would scan as a QR, with no second format invented for it (RUST.md's
|
||||||
|
E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is
|
||||||
|
persisted or under what file permissions -- a phone seals its token in the
|
||||||
|
Android Keystore, `iris/desktop-app/src/config.rs` writes it to
|
||||||
|
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
|
||||||
|
caller-specific (the code rules' "ask for the least you need"). Its only
|
||||||
|
caller today is `desktop-app`; a future Android build of this crate would
|
||||||
|
be a second one, not a reason to move the type.
|
||||||
|
|
||||||
|
## What is not started at all
|
||||||
|
|
||||||
|
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
||||||
|
from the transcript cache or the server, and stitches the two. Needs
|
||||||
|
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
|
||||||
|
which exist now, so this is unblocked whenever picked up.
|
||||||
|
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
|
||||||
|
colours a `.md` file or fence for the highlighter, but does not build the
|
||||||
|
block tree (headings, lists, tables, fences as distinct nodes) that a
|
||||||
|
renderer walks to lay out prose versus code versus a table.
|
||||||
|
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
|
||||||
|
AST is Compose rendering plumbing, not something to port as-is; a Rust
|
||||||
|
UI layer will want its own block parser or a crate for it, decided
|
||||||
|
alongside the framework choice in RUST.md.
|
||||||
|
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
||||||
|
it flattens a row into bounded units for a *specific* lazy-list
|
||||||
|
framework's composition cost, which is a fact about that framework
|
||||||
|
rather than about the transcript.
|
||||||
|
|
||||||
|
## Verifying
|
||||||
|
|
||||||
|
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
||||||
|
and `server` in that order (each `cargo test`, forwarding arguments the
|
||||||
|
same way it always has). From `client-core/` directly: `cargo test`,
|
||||||
|
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# Decisions taken for Iris to review
|
||||||
|
|
||||||
|
Short list of design choices made by the design agent without asking, so
|
||||||
|
they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
||||||
|
for iris API changes); this file is only the summary. Newest first. Items
|
||||||
|
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||||
|
|
||||||
|
## 2026-09-05
|
||||||
|
|
||||||
|
- **iris no longer asks every device for compute-shader limits it never
|
||||||
|
uses.** `adapter.request_device` (both `iris/src/android/render.rs` and
|
||||||
|
`iris/src/default/render.rs`) used `Limits::default()` plus an override
|
||||||
|
for `max_buffer_size`, and `Limits::default()` unconditionally requests
|
||||||
|
desktop-tier compute limits (`max_compute_workgroups_per_dimension:
|
||||||
|
65535`, per `wgpu_types`) even though nothing in `iris`/`iris-core`
|
||||||
|
creates a `ComputePipeline` or writes a `@compute` shader stage —
|
||||||
|
confirmed by grepping the whole tree, not assumed. That crashed
|
||||||
|
`request_device` outright on the Android emulator's software GL path
|
||||||
|
(`EMU_GPU=software`, `--features force-gles`): SwiftShader's GL reports
|
||||||
|
itself as OpenGL ES 3.0, which has no compute shaders at all, so the
|
||||||
|
adapter's real limit is 0 against the unconditional request for 65535 —
|
||||||
|
`RUST.md`'s "Software mode ... crashes for a third, different reason,"
|
||||||
|
2026-09-05, earlier today. The same would happen on any real
|
||||||
|
GLES-3.0-only Android device, not just the emulator. Fixed by a new
|
||||||
|
`iris_core::device_limits()` (`iris/core/src/render/mod.rs`), shared by
|
||||||
|
both platform backends so the two requests cannot drift, that zeros the
|
||||||
|
six `max_compute_*` fields explicitly rather than switching to a
|
||||||
|
downlevel `Limits` preset — `Limits::downlevel_webgl2_defaults()` was
|
||||||
|
considered and rejected: it also zeros
|
||||||
|
`max_storage_buffers_per_shader_stage`, and `shader.wgsl`'s vertex stage
|
||||||
|
reads four `var<storage>` buffers (rects, glyphs, masks, move_offsets),
|
||||||
|
so that preset would trade the compute crash for a bind-group-layout
|
||||||
|
one on the same downlevel hardware this is meant to support. No
|
||||||
|
capability check or fallback path was needed since nothing is being
|
||||||
|
disabled — the request is simply narrowed to what the pipeline actually
|
||||||
|
uses. `rigs/gpu-probe`'s own mirrored limits (it is deliberately its own
|
||||||
|
crate, not a workspace member, so it cannot call `device_limits()`
|
||||||
|
directly) were updated to match, and confirm `IRIS DEVICE: ok` against
|
||||||
|
this VM's own Vulkan and GL adapters. **Not verified this pass**: the
|
||||||
|
specific SwiftShader-ES-3.0 crash this fixes, on-device — the
|
||||||
|
`EMU_GPU=software` cold boot this needs would have force-restarted this
|
||||||
|
checkout's emulator while another session was actively running its own
|
||||||
|
app on it (`com.example.aiapp` had window focus at the time), so it was
|
||||||
|
left for a pass when the emulator is free rather than disrupting that
|
||||||
|
session. Everything reachable without the emulator is clean: `cargo
|
||||||
|
fmt`/`clippy --workspace --all-targets`/`test --workspace`, `cargo ndk
|
||||||
|
build`/`clippy` for `iris-android-app` with `force-gles`, and
|
||||||
|
`gpu-probe` against this VM's own Vulkan and GL(ES 3.2, which still has
|
||||||
|
compute and so would not have reproduced the crash even before this
|
||||||
|
fix — not a substitute for the real ES-3.0 test).
|
||||||
|
|
||||||
|
- **P0's Compose half is built and smoke-tested on the emulator** — the
|
||||||
|
`bench` build type, the shared `app/bench-fixture/` transcript, and an
|
||||||
|
in-process fake backend (`BenchFixture.kt`/`BenchNetwork.kt`) that
|
||||||
|
answers `TranscriptSource`/`EventStream` from an in-memory event log
|
||||||
|
instead of a real server, so the fold and paging under test are the real
|
||||||
|
ones. Full account, the smoke run's report, and what is deliberately
|
||||||
|
left (the iris half, the real on-phone runs) are in RUST.md's P0 box.
|
||||||
|
Not a decision to review so much as the gate itself now being runnable —
|
||||||
|
flagged here because it is the first half of something Iris explicitly
|
||||||
|
asked to see before P1.
|
||||||
|
|
||||||
|
- **P0's iris half is also built and smoke-tested on the emulator,
|
||||||
|
2026-09-05.** A new `bench` Cargo feature on `iris-android-app`, on top
|
||||||
|
of `transcript-screen`: the same checked-in fixture (`include_str!`, no
|
||||||
|
asset pipeline needed), the same 24-swipe scroll loop animated through
|
||||||
|
`List::scroll` and the same 400-event/20s streaming phase through
|
||||||
|
`fold_event`, "Run benchmark"/"Copy report" as named accessible
|
||||||
|
controls, and the same three added report fields (process CPU time,
|
||||||
|
peak RSS, battery current) via direct JNI calls
|
||||||
|
(`bench_jni.rs::PlatformHandle`) since `android_view` has no
|
||||||
|
`BatteryManager`/`ClipboardManager` wrapper of its own. One small public
|
||||||
|
API addition to get there: `AndroidAppState::platform_ready` (`IRIS.md`),
|
||||||
|
a default-no-op lifecycle hook handing an implementor a `JavaVM` +
|
||||||
|
`GlobalRef` it can call Java through from any thread. Packaged with a
|
||||||
|
new `release` build type on `iris-android-app`'s own Gradle project
|
||||||
|
(there was previously only `debug`), signed with the same key
|
||||||
|
`app/build-apk.sh` generates. Smoke run and the full report are in
|
||||||
|
RUST.md's P0 box; not attempted this pass: the real on-phone runs and
|
||||||
|
Iris's pass/fail call, which is the actual gate.
|
||||||
|
|
||||||
|
- **The intermittent touch-scroll dropout is root-caused and fixed: a
|
||||||
|
missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced
|
||||||
|
first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every
|
||||||
|
touch event, `DragArbiter` state transition and `Selection::drag`
|
||||||
|
dispatch (removed once confirmed), reproduced on this checkout's own
|
||||||
|
emulator against a real sandbox session. The trace showed the actual
|
||||||
|
mechanism: a gesture's `ACTION_DOWN` lands wherever the finger actually
|
||||||
|
is, which is not guaranteed to fall inside the same row-local sensor
|
||||||
|
region a later `ACTION_MOVE` in the same gesture lands in (a row's own
|
||||||
|
padding/gap, or its non-selectable sender-name header, is
|
||||||
|
pointer-transparent to `iris::sense::CursorSense`). When that happens,
|
||||||
|
the widget that ends up handling the gesture never saw `PressStart`, so
|
||||||
|
`DragArbiter` sits in `Idle` — which answers every subsequent frame with
|
||||||
|
`Undecided` and has no way to tell "no press is happening" from "a press
|
||||||
|
is happening but I missed its start," so it never recovers on its own
|
||||||
|
for the rest of that gesture. One real trace showed exactly this: touch
|
||||||
|
`Down`/`Move`/`Up` all delivered correctly, but zero `PressStart`
|
||||||
|
reaching the arbiter, `state=Idle` unchanged from first frame to last.
|
||||||
|
Fixed at the call site that has the context to recover
|
||||||
|
(`iris::transcript_ui::selection::Selection::drag`,
|
||||||
|
`iris/transcript-ui/src/selection.rs`): a new `DragArbiter::is_idle()`
|
||||||
|
(`iris/src/sense.rs`) lets it notice a `Pressing` frame arriving with the
|
||||||
|
arbiter still `Idle` — which can only mean a missed `PressStart`, since a
|
||||||
|
`Pressing` sense requires the button to genuinely be down — and start the
|
||||||
|
press there instead of where it was missed. Three new unit tests in
|
||||||
|
`sense.rs`'s `drag_arbiter_tests` and one in `transcript-ui`'s
|
||||||
|
`selection::tests` (the latter fails on the code before this fix).
|
||||||
|
Commit follows. Not the same failure the earlier pass's `DECISIONS.md`
|
||||||
|
DEFERRED item speculated about (a coalesced first `ACTION_MOVE` skipping
|
||||||
|
slop detection) — that hypothesis is now ruled out; the arbiter's own
|
||||||
|
slop/long-press logic was never wrong. RUST.md's I5 box,
|
||||||
|
"Touch-scroll dropout root-caused, 2026-09-05" has the full trace.
|
||||||
|
- **P0, a phone benchmark gate before any porting, asked for by Iris
|
||||||
|
2026-09-05**: "before P1 I'd like to see benchmarks & also maybe stress
|
||||||
|
test on my own phone ... If it doesn't match compose reasonably well then
|
||||||
|
I don't think I'd wanna continue." Design (RUST.md's P0 box has the
|
||||||
|
detail): the same embedded synthetic fixture in both apps with no server
|
||||||
|
needed; the same scripted scroll loop then a streaming phase, run
|
||||||
|
programmatically since the phone has no usable system tracing and no
|
||||||
|
agent can drive it; the same report from both (frames, janky %, p50/p90/
|
||||||
|
p99, process CPU time, peak RSS, battery current where readable) with a
|
||||||
|
copy button; the iris app under its own id and the Compose one as a new
|
||||||
|
`bench` build type with an id suffix, so neither replaces her production
|
||||||
|
install; two arm64 APKs plus instructions delivered under `~/host/bench/`.
|
||||||
|
The gate is hers: iris within a reasonable margin of Compose release on
|
||||||
|
p50, p99 and CPU time, no crashes, no visible stutter. If it fails, the
|
||||||
|
port stops.
|
||||||
|
- **The rest of the port is one UI crate, `iris/app-ui`, grown out of
|
||||||
|
`iris/transcript-ui` rather than started beside it.** It holds a
|
||||||
|
`Screen` enum plus a back stack — the Rust equivalent of `AppRoot.kt`'s
|
||||||
|
`when` — and `iris/desktop-app`/`iris/android-app` become thin entry
|
||||||
|
points over it. Chosen over a fresh crate because `transcript-ui`
|
||||||
|
already has the right generic shape (`Rsc: HasEvents` +
|
||||||
|
`Rsc::State: FocusHost`) and the `client-core`/`event-model` path
|
||||||
|
dependencies every later screen needs, so growing it in place is the
|
||||||
|
smaller diff. Platform-only code (notification service, share target,
|
||||||
|
QR scanner, Keystore token, deep-link enrolment) stays in the E3/E5
|
||||||
|
Java shell (`android-shell/` + `app/shellApp`) rather than moving into
|
||||||
|
this crate, since none of it is a screen. The Android APK is built by
|
||||||
|
`cargo xtask apk` (E5), merging the app-ui cdylib into the E3 shell so
|
||||||
|
there is one app rather than a demo shell plus a service shell.
|
||||||
|
`app/androidApp` (the Compose app) stays untouched and is the baseline
|
||||||
|
every step is measured against, until parity is reached (P7 decides
|
||||||
|
the switch, and is itself a load-bearing decision left to Iris). Order
|
||||||
|
is by risk to the daily-use path: session screen first (P1, where
|
||||||
|
every hard behaviour already lives), then the shell merge and a real
|
||||||
|
phone install (P2), then root tabs (P3), the explorer (P4),
|
||||||
|
settings/enrolment (P5), desktop parity (P6), and the cutover itself
|
||||||
|
(P7). Full plan: RUST.md's "The port, in order (decided 2026-09-05)".
|
||||||
|
- **iris gets its own measured frame report, rather than waiting on a
|
||||||
|
`dumpsys`/`gfxinfo` answer that cannot see a `SurfaceView`'s GPU-drawn
|
||||||
|
frames.** `iris_core::FrameReport` (`iris/core/src/render/frame_report.rs`)
|
||||||
|
times each frame's wall clock from the same point `render()`'s redraw
|
||||||
|
starts to just after `queue.submit` + `present()` — the span Compose's
|
||||||
|
own render report and `gfxinfo` both count — into a fixed 4096-entry
|
||||||
|
ring (no allocation per frame; `report()` is the only place that
|
||||||
|
allocates, and only on a button tap). The report gives total frames,
|
||||||
|
janky % over the same 16.7ms budget `gfxinfo` uses, P50/P90/P99 and the
|
||||||
|
worst, plus a reset. Exposed the way the Compose app's copy-button
|
||||||
|
report already is: two named controls ("Frame report", "Reset frame
|
||||||
|
report") on the transcript screen, tappable by accessibility name via
|
||||||
|
`ui-trace`, logging under this crate's fixed `android_logger` tag
|
||||||
|
(`iris-android-app`) so a script can grep `"iris frame report"` the way
|
||||||
|
`transcript-bench.sh` greps `"ai-app render report"`. The report's own
|
||||||
|
`Display` line says plainly that it measures up to the `present()` call
|
||||||
|
returning, not GPU/compositor completion — wgpu's `present()` is not
|
||||||
|
fenced against either, so presenting that span as "time to reach the
|
||||||
|
screen" would be a measured-looking number that is actually inferred,
|
||||||
|
which the standing UI rule forbids.
|
||||||
|
- **`ui-trace` gains a hold-then-drag gesture, additive, in
|
||||||
|
`emulator-tools`.** Neither of its two existing actions can produce
|
||||||
|
"hold stationary for `LONG_PRESS`, then move without lifting" — `tap`
|
||||||
|
has no hold and `swipe X1 Y1 X2 Y2 MS` interpolates motion across its
|
||||||
|
whole duration from t=0. A new action presses, waits, then moves to a
|
||||||
|
second point and releases as one continuous touch (raw
|
||||||
|
`sendevent`/`MotionEvent` injection, extending whatever mechanism the
|
||||||
|
existing `swipe` already uses), so `DragArbiter`'s pan-vs-select rule
|
||||||
|
(`iris/src/sense.rs`, already covered by 8 unit tests against a
|
||||||
|
synthetic clock) can finally be driven on a real device instead of only
|
||||||
|
in a test harness.
|
||||||
|
- **Touch drag on a transcript row follows Android's own rule**: a vertical
|
||||||
|
drag pans the list immediately; a stationary press held 500 ms starts a
|
||||||
|
text selection which further dragging extends; a horizontal drag while
|
||||||
|
something is already selected extends that selection without the wait.
|
||||||
|
One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a
|
||||||
|
"text layer always wins" or "list always wins" rule because either loses
|
||||||
|
one of the two gestures a reader expects.
|
||||||
|
- **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window
|
||||||
|
holding `transcript-ui`'s screen beside a session list, talking to a real
|
||||||
|
`ai-server` through `client-core`. It enrols by pasting the same
|
||||||
|
`aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`)
|
||||||
|
and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The
|
||||||
|
pinned CA is a path given on the command line, not baked in. Chosen so
|
||||||
|
the phone and desktop share one enrolment format and no second one is
|
||||||
|
invented.
|
||||||
|
- **I5's Android integration extends `iris-android-app` (I2's shell)
|
||||||
|
behind a Cargo feature (`transcript-screen`), rather than a third
|
||||||
|
shell crate.** That project already has the Gradle module, the
|
||||||
|
`IrisView`/`MainActivity` Java, and the JNI registration; the only
|
||||||
|
thing a second screen needs on top is a different `AndroidAppState`,
|
||||||
|
the same axis `tabs_ui::build`/`transcript_ui::build` already vary
|
||||||
|
along on the winit side. `tabs-screen`/`transcript-screen` are
|
||||||
|
mutually exclusive and each pulls in only its own deps, so the plain
|
||||||
|
tabs build (I2/I4) is untouched.
|
||||||
|
- **Order of remaining work, updated 2026-09-05**: the two in-flight
|
||||||
|
pieces and I5's Android integration are all done; next is giving iris
|
||||||
|
its own frame-timing report so item 3 below can be decided by a number.
|
||||||
|
- **DECIDED by Iris, 2026-09-05: iris is the app's framework; Masonry was
|
||||||
|
the calibration.** Her words: "I think iris definitely makes more sense
|
||||||
|
based on the limitations we've found." The limitations: Masonry has no
|
||||||
|
touch scroll on Android (E2), no per-span rich text and no cross-row
|
||||||
|
selection on the pinned commit (E2), and its keyboard bridge is a TODO
|
||||||
|
(E1); iris carries the same screen under the Compose baseline on the
|
||||||
|
host GPU (p50 15.0 ms against Compose's 20.0 ms, RUST.md's I5 box). What
|
||||||
|
follows: the E-steps are closed as calibration, and the port proceeds
|
||||||
|
on iris — screens, the shell (E3/E5), and `client-core` underneath.
|
||||||
|
The item below is kept as the record of what she decided from.
|
||||||
|
- **Was DEFERRED — whether to commit to iris over Masonry for `ai-app`.**
|
||||||
|
Updated 2026-09-05 with the clean comparison the recommendation wanted:
|
||||||
|
same sandbox session content, same emulator, `EMU_GPU=software`, one
|
||||||
|
session. Headline numbers (RUST.md's I5 box, "Clean scroll comparison,
|
||||||
|
2026-09-05," has the full table and every caveat):
|
||||||
|
|
||||||
|
| app | build | frames | janky % | p50 | p90 | p99 | worst |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Compose (in-app report) | debug | 1102 | 99.0% late | 33.8ms | 50.6ms | 79.5ms | -- |
|
||||||
|
| Compose (`dumpsys gfxinfo`) | debug | 1499 | 21.15% (95.66% legacy) | 32ms | 48ms | 150ms (p99) | -- |
|
||||||
|
| iris (`FrameReport`) | **release** | 299 | 94.65% | 79.1ms | 98.6ms | 117.8ms | 212.6ms |
|
||||||
|
| iris (`FrameReport`, repeat) | **release** | 233 | 94.42% | 109.3ms | 130.8ms | 147.1ms | 150.5ms |
|
||||||
|
|
||||||
|
**Not a clean apples-to-apples reading, stated plainly rather than
|
||||||
|
smoothed over**: iris had to be built **release** (debug `SIGSEGV`s on
|
||||||
|
this emulator's Vulkan loader, I4's finding) against Compose's mandated
|
||||||
|
**debug** build, so this asymmetry likely *understates* iris's gap
|
||||||
|
rather than the reverse; the three frame-time sources measure different
|
||||||
|
things (Compose's own phase accounting vs. Android's HWUI deadline-miss
|
||||||
|
definition vs. iris's redraw-start-to-present window, the last of which
|
||||||
|
`dumpsys gfxinfo` cannot see at all for iris's `SurfaceView`); and both
|
||||||
|
figures are emulator numbers under software rasterisation, which
|
||||||
|
Compose's *own* in-app report shows already costs 20-34ms/frame in
|
||||||
|
`swap`+`gpu` alone under this GPU mode, so a same-mode iris number well
|
||||||
|
above 16.7ms was expected going in for either app. A second pair under
|
||||||
|
`-gpu host` was not taken this pass. The earlier session's suspected
|
||||||
|
intermittent touch-delivery dropout was **not reproduced** this pass —
|
||||||
|
the zero-frame results this time traced to this pass's own script bug
|
||||||
|
(a `cd` that changed which emulator `ui-trace` targeted), not the
|
||||||
|
emulator; a CPU-load rise during the gesture was observed by a sampler
|
||||||
|
running throughout, but did not correlate with any failure, so the
|
||||||
|
original candidate is neither confirmed nor ruled out.
|
||||||
|
The choice in front of Iris, updated: decide now on the
|
||||||
|
structural-plus-functional case already made (iris works end-to-end
|
||||||
|
where Masonry's scroll gesture doesn't exist at all on Android) plus
|
||||||
|
this table — reading the two build profiles and three jank definitions
|
||||||
|
with the caveats above rather than as a single number — or ask for a
|
||||||
|
same-profile, same-GPU-mode rerun first. RUST.md's I5 box has the full
|
||||||
|
account.
|
||||||
|
|
||||||
|
**Updated 2026-09-05, the `-gpu host` pair taken.** Real GPU rendering
|
||||||
|
(`force-gles` -- the default Vulkan backend has no adapter at all under
|
||||||
|
plain host-GPU boot, confirmed by the exact `wgpu` error) reverses the
|
||||||
|
software-mode shape:
|
||||||
|
|
||||||
|
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- |
|
||||||
|
| iris (`FrameReport`), **best of three, 2026-09-05** | release, `force-gles` | host (virgl) | 439 | 46.24% | 15.7ms | 23.3ms | 31.2ms | 57.4ms | 1.2ms | 13.2ms |
|
||||||
|
|
||||||
|
Under real GPU rendering iris's median frame is *faster* than
|
||||||
|
Compose's, not the 2-3x-slower shape the software-mode table shows. A
|
||||||
|
new split inside `FrameReport` (redraw-to-submit vs. submit-to-present,
|
||||||
|
commit `e2a1fad`) says why: iris's own CPU work per frame is a median
|
||||||
|
~1ms -- almost the entire frame is time spent handing the frame to the
|
||||||
|
driver, not in iris's layout/text/primitive code. This is consistent
|
||||||
|
with the earlier software-mode gap being mostly SwiftShader's CPU
|
||||||
|
rasterisation cost rather than an iris-specific slowness. **Still not
|
||||||
|
proof, and now closed as unanswerable rather than merely untaken**: a
|
||||||
|
same-mode software `force-gles` run to isolate the backend was retried
|
||||||
|
2026-09-05 after fixing the compute-limit crash the first attempt hit,
|
||||||
|
and hit a second, structural wall instead — SwiftShader's ES 3.0 GL
|
||||||
|
path has no storage-buffer capacity at all, and `shader.wgsl` reads
|
||||||
|
`var<storage>` buffers unconditionally, so reaching that path needs a
|
||||||
|
shader rewrite, not a limits fix (RUST.md's I5 box, "The three
|
||||||
|
remaining I5 verifications, closed 2026-09-05," item 2). The
|
||||||
|
intermittent touch-scroll dropout this pass also reproduced is
|
||||||
|
root-caused and fixed as of the same date (a missed `ACTION_DOWN` on a
|
||||||
|
row's padding/header left `DragArbiter` stuck in `Idle`); three clean
|
||||||
|
`iris-scroll.sh` runs post-fix each scrolled all 24/24 swipes, replacing
|
||||||
|
the single-attempt 62-frame reading this table used to carry. RUST.md's
|
||||||
|
I5 box, "Where iris's frame time goes, 2026-09-05, the `-gpu host`
|
||||||
|
pass," and "The three remaining I5 verifications, closed 2026-09-05,"
|
||||||
|
have the full account. The iris-vs-Masonry choice itself is still
|
||||||
|
Iris's to make.
|
||||||
File renamed without changes.
+682
@@ -0,0 +1,682 @@
|
|||||||
|
# iris: notable public API changes
|
||||||
|
|
||||||
|
For Iris to read on her own time. Each entry is a change to iris's public
|
||||||
|
surface that a widget author or app author would notice: a trait method
|
||||||
|
added, removed or re-shaped; a type that callers construct differently; a
|
||||||
|
capability that moved. Small and trivial changes do not go here.
|
||||||
|
|
||||||
|
An entry gives the date, what changed, why, and a short before/after where
|
||||||
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
|
## 2026-09-06: `List::anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/`late_at_hz` (RUST.md's "Benchmark v2")
|
||||||
|
|
||||||
|
`List` gained `anchor_position_display(&self) -> String`, reporting the
|
||||||
|
anchor's own row index and pixel offset (`idx=N/off=Mpx`, or
|
||||||
|
`idx=more-before`/`idx=more-after`/`idx=none`) -- what a scripted
|
||||||
|
benchmark reads to report fling travel. Note the anchor does not
|
||||||
|
necessarily change *slot* over a long scroll (this widget's own documented
|
||||||
|
design: the anchor is a stable identity, not re-derived from what's on
|
||||||
|
screen each frame), so this is not the same measurement as a Compose
|
||||||
|
`LazyListState.firstVisibleItemIndex`, which does track the true topmost
|
||||||
|
visible row -- the `off` half is what actually reflects how far a fling
|
||||||
|
travelled.
|
||||||
|
|
||||||
|
`iris_core::render::frame_report::FrameReport` gained three methods for
|
||||||
|
per-phase benchmark reporting: `mark_phase(name)` records a named phase
|
||||||
|
boundary at the current frame/instant; `phase_stats(now, refresh_hz)`
|
||||||
|
returns one `PhaseStats` (frames, wall duration, late count/percent,
|
||||||
|
p50/p90/p99, worst) per marked phase, sliced from the existing ring by a
|
||||||
|
new parallel `index_ring`; `late_at_hz(refresh_hz)` gives the whole run's
|
||||||
|
late count/percent judged against an arbitrary refresh rate rather than
|
||||||
|
the fixed 60Hz `JANK_THRESHOLD` every existing caller still uses (a
|
||||||
|
separate method, not a parameter on `report()`, so nothing else changes
|
||||||
|
behaviour). `RING_CAPACITY` grew 4096->16384 to hold a full multi-phase
|
||||||
|
run without evicting earlier phases' samples.
|
||||||
|
|
||||||
|
## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum")
|
||||||
|
|
||||||
|
`iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
|
||||||
|
one (cancelled by the next touch-down via `cancel_fling`, or automatically
|
||||||
|
once it settles or reaches loaded content's start/end), `is_scrolling()`
|
||||||
|
reports whether one is running, and `tick_fling(now: Instant) -> bool`
|
||||||
|
advances it and returns whether it is still going -- a caller that owns a
|
||||||
|
`RequestRedraw` handle can hand it to the list once via the new
|
||||||
|
`set_redraw_handle`, after which `List` re-arms its own next frame while
|
||||||
|
flinging with no further polling needed; a caller driving a scripted
|
||||||
|
benchmark instead calls `tick_fling` itself in a loop, same as it already
|
||||||
|
drives `scroll`.
|
||||||
|
|
||||||
|
The physics is `iris::sense::FlingCalculator` + `VelocityTracker`
|
||||||
|
(`sense.rs`, beside `DragArbiter`): a port of AOSP `SplineOverScroller`'s
|
||||||
|
deceleration curve (the same one Compose's own `ScrollableDefaults.
|
||||||
|
flingBehavior()` uses), cited at the definition, so a fling here travels
|
||||||
|
the same distance a Compose `LazyColumn` would for the same initial
|
||||||
|
velocity. `VelocityTracker` estimates that velocity from the drag's last
|
||||||
|
~100ms of samples rather than one frame's last delta. Unit-tested:
|
||||||
|
velocity from known samples, fling distance/duration against the closed-
|
||||||
|
form spline result (within 1%), cancel-on-touch, and the start/end clamp
|
||||||
|
(a fling stops rather than scrolling into content that was never loaded).
|
||||||
|
|
||||||
|
Before: a touch-drag panned exactly as far as the finger moved and stopped
|
||||||
|
dead on release. After: releasing mid-drag continues scrolling and
|
||||||
|
decelerates, matching the muscle memory every other Android scroll view
|
||||||
|
already trained. `transcript_ui::selection::Selection::drag` wires this in
|
||||||
|
-- a release only flings if the gesture had committed to panning
|
||||||
|
(`DragArbiter::is_panning`, new), never a selection or an undecided tap.
|
||||||
|
|
||||||
|
## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix)
|
||||||
|
|
||||||
|
`iris_core::UiRenderNode::new(device, queue, config)` now returns
|
||||||
|
`Result<Self, String>` instead of `Self`. Why: it used to let a bind-group-
|
||||||
|
layout validation failure reach wgpu's default error handler, which panics
|
||||||
|
with no way for a caller to intervene -- exactly what aborted the P0 bench
|
||||||
|
APK on Iris's phone with the crash report truncated to "wgpu error:
|
||||||
|
Validation Error" and nothing else recoverable. It now runs its creation
|
||||||
|
calls inside wgpu error scopes and returns the full error text (wgpu's own
|
||||||
|
"Caused by" chain) as `Err` instead.
|
||||||
|
|
||||||
|
Both callers changed to match: `android::render::AndroidRenderer::new`
|
||||||
|
itself now returns `Result<Self, String>` too, building a fuller report
|
||||||
|
(adapter identity, the limits/downlevel flags a layout validates against,
|
||||||
|
then wgpu's text) on failure -- its caller,
|
||||||
|
`android::view::IrisViewPeer::surface_changed`, logs that report as one
|
||||||
|
logcat line and shows it on screen (a new `IrisView.showRendererError`,
|
||||||
|
called via an ordinary JNI method call rather than a new `native fn`)
|
||||||
|
instead of letting the process abort. `default::render::UiRenderer::new`
|
||||||
|
(the winit/desktop backend) still panics on failure -- there is no
|
||||||
|
on-screen fallback there -- but the panic message is now the same full
|
||||||
|
text rather than whatever wgpu's own handler would have printed.
|
||||||
|
|
||||||
|
No change for an app that never constructs a `UiRenderNode` directly (every
|
||||||
|
current one goes through `AndroidRenderer`/`UiRenderer`), but anyone who
|
||||||
|
does needs an `?`/`.expect()`/`match` at the call site now. Full audit and
|
||||||
|
the named hypothesis for what actually failed on the phone are in
|
||||||
|
RUST.md's P0 box, "iris bench crash on the phone, 2026-09-06."
|
||||||
|
|
||||||
|
## 2026-09-05: `AndroidAppState::platform_ready` (RUST.md's P0 box, iris half)
|
||||||
|
|
||||||
|
Added a second, optional lifecycle method to `iris::android::AndroidAppState`
|
||||||
|
(`iris/src/android/view.rs`), called once from `new_peer` right after `new`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Default does nothing, so every existing implementor (`Client`,
|
||||||
|
`TranscriptClient`) is unaffected. It exists for a caller that needs to call
|
||||||
|
into Java itself beyond what a `RequestRedraw` handle already covers --
|
||||||
|
P0's bench build (`iris-android-app`'s new `bench` feature,
|
||||||
|
`bench_client.rs`/`bench_jni.rs`) uses it to hold a `JavaVM` + `GlobalRef`
|
||||||
|
to the view so its "Copy report" control and once-a-second battery sampler
|
||||||
|
can call `BatteryManager`/`ClipboardManager` through the view's own
|
||||||
|
`Context`, from a background tokio task as well as the UI thread. `new`
|
||||||
|
itself was not extended with these two parameters: most implementors need
|
||||||
|
nothing here, and `new`'s job is building the widget tree, not holding a
|
||||||
|
platform handle. `vm`/`view` are independent handles from the ones
|
||||||
|
`new_peer` keeps for its own `RequestRedraw` (a fresh `get_java_vm`/
|
||||||
|
`new_global_ref` each), so storing them has no effect on that mechanism.
|
||||||
|
|
||||||
|
## 2026-09-05 (later still): `iris_core::device_limits()`, and iris no longer requests compute-shader limits
|
||||||
|
|
||||||
|
New public function, `iris_core::device_limits() -> wgpu::Limits`. Why:
|
||||||
|
`adapter.request_device`'s `required_limits` was `Limits::default()` plus
|
||||||
|
a `max_buffer_size` override in both platform backends, and
|
||||||
|
`Limits::default()` requests desktop-tier compute-shader limits
|
||||||
|
unconditionally (`max_compute_workgroups_per_dimension: 65535`) even
|
||||||
|
though nothing in `iris`/`iris-core` uses a `ComputePipeline` — that
|
||||||
|
crashed device creation outright on a downlevel GL adapter reporting
|
||||||
|
OpenGL ES 3.0 (no compute shaders at all: the Android emulator's
|
||||||
|
`EMU_GPU=software` path, and any real GLES-3.0-only Android device).
|
||||||
|
`device_limits()` is what both `android::render::AndroidRenderer::new`
|
||||||
|
and `default::render::UiRenderer::new` now build their `required_limits`
|
||||||
|
from, so the request cannot drift between the two backends.
|
||||||
|
|
||||||
|
Before: `Limits { max_buffer_size: 1 << 30, ..Default::default() }`
|
||||||
|
inlined in each backend. After: `iris_core::device_limits()`, which is
|
||||||
|
the same thing with the six `max_compute_*` fields additionally zeroed.
|
||||||
|
A caller building its own `DeviceDescriptor` outside these two backends
|
||||||
|
(there are none today, but a third platform backend would want this)
|
||||||
|
should call `device_limits()` rather than reaching for
|
||||||
|
`Limits::default()` directly, unless it genuinely adds a compute pass —
|
||||||
|
in which case it wants the specific compute limits that pass needs, not
|
||||||
|
the desktop-tier default for everything.
|
||||||
|
|
||||||
|
## 2026-09-05 (later the same day): `iris_core::FrameReport` (RUST.md's I5 box)
|
||||||
|
|
||||||
|
New public type, `iris_core::FrameReport` (re-exported from `iris_core`'s
|
||||||
|
`render` module alongside `FrameStats` and `JANK_THRESHOLD`). Why: `dumpsys
|
||||||
|
gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all, so a
|
||||||
|
`wgpu`-rendered iris screen had no way to ask "was this smooth" the way
|
||||||
|
Compose's own in-app render report already can -- item 3 of RUST.md's
|
||||||
|
recommendation was stuck on a one-sided number for exactly this reason.
|
||||||
|
|
||||||
|
`FrameReport::record(elapsed: Duration)` is called once per frame (wired
|
||||||
|
into `android/view.rs`'s `render()`, wrapping the same span from redraw
|
||||||
|
start to after `queue.submit`+`present()` that Compose's report and
|
||||||
|
`gfxinfo` both count) and writes into a fixed 4096-entry ring -- no
|
||||||
|
allocation on the hot path. `FrameReport::report() -> Option<FrameStats>`
|
||||||
|
gives total frames, janky % (over `JANK_THRESHOLD`, the same 16.7ms 60Hz
|
||||||
|
budget `gfxinfo` uses), P50/P90/P99 and the worst; `None` if nothing has
|
||||||
|
been recorded since the last `reset()`, not a zeroed report that would
|
||||||
|
read as a real measurement. `FrameStats`'s `Display` line says plainly
|
||||||
|
that it measures up to `present()` being called, not GPU/compositor
|
||||||
|
completion, since wgpu's `present()` isn't fenced against either.
|
||||||
|
|
||||||
|
`AndroidUiState` gained a `pub frame_report: FrameReport` field --
|
||||||
|
anything with `HasAndroidUiState` can now read or reset it. Before this,
|
||||||
|
there was no way to ask iris's own render path how long a frame took at
|
||||||
|
all, on any backend.
|
||||||
|
|
||||||
|
Before/after, for a caller that already has `ui_state: &AndroidUiState`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before: no such question could be asked
|
||||||
|
// after:
|
||||||
|
match ui_state.frame_report.report() {
|
||||||
|
Some(stats) => log::info!("iris frame report: {stats}"),
|
||||||
|
None => log::info!("iris frame report: no frames recorded yet"),
|
||||||
|
}
|
||||||
|
ui_state.frame_report.reset(); // via android_state_mut()
|
||||||
|
```
|
||||||
|
|
||||||
|
`iris-android-app`'s transcript screen exposes this as two named,
|
||||||
|
tappable controls ("Frame report", "Reset frame report") rather than
|
||||||
|
requiring a caller to wire its own UI -- see `transcript_client.rs`'s
|
||||||
|
`frame_report_controls`.
|
||||||
|
|
||||||
|
## 2026-09-05: `Tasks::redraw_handle` (RUST.md's I5 Android integration)
|
||||||
|
|
||||||
|
New public method on `iris::task::Tasks`, `redraw_handle(&self) ->
|
||||||
|
Arc<dyn RequestRedraw>`. Why: a caller running its own long-lived loop
|
||||||
|
*inside* one spawned task (a live SSE follow, the Android transcript
|
||||||
|
client's `select_session`) has no other way to ask for a frame after each
|
||||||
|
`TaskCtx::update` -- `Tasks::spawn`'s own wrapper only requests one, after
|
||||||
|
the whole async closure finishes, which fits a single request-then-update
|
||||||
|
but not a stream that needs to be seen redrawing after *each* event. This
|
||||||
|
is the same gap `iris/desktop-app`'s module doc names for why it uses
|
||||||
|
winit's `Proxy<AppEvent>` instead of `Tasks` -- android-view has no
|
||||||
|
`Proxy`, so this is what closes it there.
|
||||||
|
|
||||||
|
**A real bug this uncovered, not a hypothetical**: calling the returned
|
||||||
|
handle's `request_redraw()` from the background thread crashed the process
|
||||||
|
(`SIGABRT`, `Result::unwrap() on an Err value: JavaException`) the first
|
||||||
|
time an Android transcript fetch called it a second time. `android/render.rs`'s
|
||||||
|
`AndroidRedrawHandle` was already attaching the calling thread to the JVM
|
||||||
|
correctly, but its `request_redraw` called `View::post_frame_callback`,
|
||||||
|
whose Java side calls `Choreographer.getInstance()` -- which throws unless
|
||||||
|
the *calling* thread already has a `Looper`, and a tokio worker thread,
|
||||||
|
even freshly JNI-attached, has none. Fixed by routing through
|
||||||
|
`View::post_delayed(0)` instead (Android's own thread-safe "queue work onto
|
||||||
|
this View's UI thread" primitive, needing no caller-side `Looper`), landing
|
||||||
|
on a new `IrisViewPeer::delayed_callback` override that drains tasks and
|
||||||
|
renders -- same body as `do_frame`, on the UI thread where
|
||||||
|
`post_frame_callback` is safe again. Any future caller of `redraw_handle()`
|
||||||
|
from a background thread gets this for free; nothing about the fix is
|
||||||
|
specific to the transcript screen.
|
||||||
|
|
||||||
|
## 2026-09-05: `transcript_ui::build_tree` (RUST.md's E4)
|
||||||
|
|
||||||
|
`transcript_ui::build` claimed the whole window (`ui_state.set_root(tree)`)
|
||||||
|
as its last step, which is right for a window that *is* the transcript
|
||||||
|
screen (the winit example, an eventual Android cdylib) and wrong for the
|
||||||
|
desktop app, which puts a session list beside it. `build_tree` is `build`
|
||||||
|
minus that last step: it returns `(TranscriptScreen, StrongWidget)` instead
|
||||||
|
of just `TranscriptScreen`, and the caller decides where the tree goes —
|
||||||
|
into `ui_state.set_root`, or into a `WidgetPtr` alongside something else
|
||||||
|
(`iris/desktop-app`'s `rebuild_transcript`). `build` is now one line calling
|
||||||
|
`build_tree` and doing the `set_root` itself, so existing callers are
|
||||||
|
unaffected.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before, and still available, for a caller that wants to *be* the window:
|
||||||
|
let screen = transcript_ui::build(rsc, &mut ui_state, rows);
|
||||||
|
|
||||||
|
// new, for a caller embedding the screen beside something else:
|
||||||
|
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
||||||
|
some_widget_ptr(rsc).set(tree);
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
|
||||||
|
|
||||||
|
New public type, `iris::sense::DragArbiter`. Why: a widget author who
|
||||||
|
registers both a list-level pan and a row-level drag-to-select on the same
|
||||||
|
touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s
|
||||||
|
`run_sensors` always gives the innermost layer first refusal, so the inner
|
||||||
|
one wins every frame it is pressed, not just the frame the press started
|
||||||
|
(this is exactly what left transcript-ui's touch-drag panning unreachable
|
||||||
|
until now). `DragArbiter` is one small state machine, one instance per
|
||||||
|
gesture surface (a whole list, not per row), that a caller drives with its
|
||||||
|
own `press_start`/`update`/`release` calls and a caller-supplied `Instant`
|
||||||
|
(so it is unit-testable without a real clock or a render harness). It
|
||||||
|
decides the way Android itself does: an ordinary vertical drag pans
|
||||||
|
immediately; a stationary press held `LONG_PRESS` (500ms) starts a
|
||||||
|
selection, which any further drag then extends; a horizontal drag while
|
||||||
|
something is already selected extends it immediately, skipping the wait.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// One per list, held alongside whatever state coordinates the rows:
|
||||||
|
let mut arbiter = DragArbiter::new();
|
||||||
|
|
||||||
|
// On press-down:
|
||||||
|
arbiter.press_start(pos, Instant::now(), already_selected);
|
||||||
|
// Every frame the button/finger stays down:
|
||||||
|
match arbiter.update(pos, Instant::now()) {
|
||||||
|
DragOutcome::Pan(dy) => list.scroll(-dy),
|
||||||
|
DragOutcome::SelectStart => selection.begin(...),
|
||||||
|
DragOutcome::SelectExtend => selection.extend(...),
|
||||||
|
DragOutcome::Undecided => {}
|
||||||
|
}
|
||||||
|
// On release:
|
||||||
|
arbiter.release();
|
||||||
|
```
|
||||||
|
|
||||||
|
`transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is
|
||||||
|
the reference caller: every row's `CursorSense::click_or_drag() |
|
||||||
|
CursorSense::unclick()` handler routes through one `Selection`-owned
|
||||||
|
arbiter instead of calling `begin`/`extend` directly, so a drag that starts
|
||||||
|
on a row's own rendered text now pans the list correctly instead of
|
||||||
|
always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s
|
||||||
|
`drag_arbiter_tests` module.
|
||||||
|
|
||||||
|
### 2026-09-05, later: `DragArbiter::is_idle()`, recovering a missed `press_start`
|
||||||
|
|
||||||
|
Follow-up to the above, from a real touch-scroll dropout: a gesture's
|
||||||
|
`ACTION_DOWN` can land on a caller's own dead space (a row's padding, a
|
||||||
|
gap, a header with no handler) that never calls `press_start`, so the
|
||||||
|
first frame the arbiter actually sees is a `Pressing`-shaped `update`
|
||||||
|
with no matching start. Before this, `update`'s `Idle` arm had no way to
|
||||||
|
tell that apart from "nothing is happening" and answered `Undecided`
|
||||||
|
forever for the rest of that gesture. `is_idle(&self) -> bool` lets a
|
||||||
|
caller notice the gap and recover: if `is_idle()` is true on a frame the
|
||||||
|
caller knows a press is genuinely down (its own `Pressing`/equivalent
|
||||||
|
sense fired), call `press_start` right there instead of assuming one
|
||||||
|
already happened. `transcript-ui`'s `Selection::drag` is the reference
|
||||||
|
caller — one new match arm, checked before the ordinary `update`-only
|
||||||
|
case. Any other `DragArbiter` caller with the same "one sensor per
|
||||||
|
sub-region, no fallback for dead space" shape has the same gap and wants
|
||||||
|
the same recovery.
|
||||||
|
|
||||||
|
## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5)
|
||||||
|
|
||||||
|
A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size,
|
||||||
|
family, ...) for its whole string, applied via `push_default` into parley's
|
||||||
|
ranged builder. `SpanStyle` is a second, optional layer: a byte range plus
|
||||||
|
whichever of colour/family/font size/bold/italic/underline it overrides,
|
||||||
|
pushed with parley's own `push(property, range)` instead. Why: a transcript
|
||||||
|
row's markdown (a heading, **bold**, `inline code`, a link) all inside one
|
||||||
|
wrapped paragraph needs each to carry its own look while the paragraph
|
||||||
|
still wraps and selects as a single buffer — the thing `masonry`'s
|
||||||
|
`TextArea` cannot do (`StyleSet` is one style for the whole editor,
|
||||||
|
`text_area.rs:43-44`'s `// TODO: RichTextInput`), and the reason this
|
||||||
|
existed at all.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0);
|
||||||
|
wtext(text)
|
||||||
|
.spans(spans) // new: TextBuilder::spans, on both Text and TextEdit
|
||||||
|
.editable(EditMode::MultiLine)
|
||||||
|
.add(rsc);
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things a widget author should know before reaching for it:
|
||||||
|
|
||||||
|
- **Call `.spans()` before or after `.editable()`, both work** — the field
|
||||||
|
lives on `TextBuilder` itself, not either output type, and both
|
||||||
|
`TextOutput::run` and `TextEditOutput::run` apply it to the buffer via
|
||||||
|
`TextBuffer::set_spans`. **These two call sites are a pair**: adding a
|
||||||
|
third `TextBuilderOutput` impl without also calling `set_spans` there
|
||||||
|
reproduces the exact bug this box shipped once already (spans silently
|
||||||
|
dropped for `TextEdit`, found only by screenshotting, not by any test —
|
||||||
|
`markdown.rs`'s own unit tests check string/range logic, which is
|
||||||
|
correct in isolation and proves nothing about whether the render path
|
||||||
|
ever sees it).
|
||||||
|
- **Colour is now per-glyph, not per-buffer.** `PlacedGlyph` gained a
|
||||||
|
`color: UiColor` field (from parley's own per-run `Style::brush`), and
|
||||||
|
`Painter::glyphs` draws each glyph in its own colour instead of
|
||||||
|
`RenderedText::color` uniformly. `RenderedText::color` still exists (the
|
||||||
|
buffer's *base* colour, for a caller that wants it as a whole, e.g. to
|
||||||
|
tint a cursor) but no longer drives what a glyph actually renders as.
|
||||||
|
|
||||||
|
## 2026-09-05: accessibility names via AccessKit (RUST.md's I4)
|
||||||
|
|
||||||
|
`.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree)
|
||||||
|
is now load-bearing: it's the one thing that puts a widget in the AccessKit
|
||||||
|
tree `iris_core::ui::access::AccessTree` builds and both backends push
|
||||||
|
out. A widget author who wants a control to be findable by name (and
|
||||||
|
tappable by name, through `ui-trace`/a real screen reader) calls `.label()`
|
||||||
|
on it; nothing else is required, and a widget nobody labels is invisible
|
||||||
|
to this system at zero cost, not just zero UI.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let button = rect(Color::LIME)
|
||||||
|
.on(CursorSense::click(), move |_, rsc| { ... })
|
||||||
|
.label("Add task"); // now findable by uiautomator/AccessKit as "Add task"
|
||||||
|
```
|
||||||
|
|
||||||
|
Two new things a widget author might touch directly:
|
||||||
|
|
||||||
|
- **`Widget::access_role(&self) -> accesskit::Role`**, default `Unknown`.
|
||||||
|
Override it if your widget has a real platform equivalent —
|
||||||
|
`TextEdit` now returns `TextInput`/`MultilineTextInput` by `EditMode`.
|
||||||
|
Only consulted for a widget that also has a `.label()`; an unlabelled
|
||||||
|
widget's `access_role` is never called.
|
||||||
|
- **`Widgets::named() -> impl Iterator<Item = WidgetId>`** — every widget
|
||||||
|
with an explicit label, for anything else that wants to walk the same
|
||||||
|
set `AccessTree` does.
|
||||||
|
|
||||||
|
Nothing about `Painter`, `draw`, or the layout/move machinery changed —
|
||||||
|
this sits entirely beside them, reading `resolved_region`'s output rather
|
||||||
|
than participating in producing it.
|
||||||
|
|
||||||
|
## 2026-09-05: `List`, a virtualised bottom-anchored list (RUST.md's I3)
|
||||||
|
|
||||||
|
A new widget, `iris::widget::List` (`iris/src/widget/list.rs` -- read its
|
||||||
|
module doc first), for the transcript's kind of screen: variable-height
|
||||||
|
rows, keyed by a `u64`, composed only while visible, moved rather than
|
||||||
|
re-laid-out on scroll, a scroll anchor that survives a row inserted above
|
||||||
|
it, "more" sentinels at each end, and "hold the edge nearest the tap" when
|
||||||
|
a row's height changes (`note_tap`, resolved in the layout pass).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut list = List::new(Axis::Y);
|
||||||
|
list.push_back(ListRow::new(key, row_widget)); // O(1)
|
||||||
|
list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected
|
||||||
|
list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge
|
||||||
|
list.note_tap(viewport_y); // before mutating a row's height
|
||||||
|
let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible
|
||||||
|
```
|
||||||
|
|
||||||
|
Built entirely out of existing primitives (`Painter::widget`/`widget_within`/
|
||||||
|
`reposition`/`draw_twice`, and `draw_inner`'s own old-children diffing) --
|
||||||
|
no new mechanism was added to the render core for it. One correctness
|
||||||
|
lesson worth reading even for other widgets: a row that fills whatever
|
||||||
|
region it is offered (`Rect`, `is_size_independent`) cannot be measured at
|
||||||
|
a throwaway oversized region and then merely `reposition`ed into place --
|
||||||
|
`reposition` only ever writes an offset, never a size, so the oversized
|
||||||
|
primitive stays oversized. `List` fixes this by caching each row's real
|
||||||
|
height once measured and placing an already-known row directly at its
|
||||||
|
exact box; see `list.rs`'s `place` for the full reasoning and
|
||||||
|
`a_fill_shaped_background_is_not_left_oversized` for the regression test.
|
||||||
|
|
||||||
|
## 2026-09-05: a second backend (android-view), and what moved to make room for it
|
||||||
|
|
||||||
|
RUST.md's I2. Three changes a widget or app author would notice, all in
|
||||||
|
service of the same thing: `default` (winit) and the new `android`
|
||||||
|
(android-view) backends sharing what does not depend on windowing.
|
||||||
|
|
||||||
|
- **`Selector`/`Selectable`'s bound changed from `Rsc::State:
|
||||||
|
HasDefaultUiState` to `Rsc::State: FocusHost`** (new trait, `attr.rs`).
|
||||||
|
`HasDefaultUiState` still exists and still works — `default/attr.rs` now
|
||||||
|
implements `FocusHost` for anything that has it — so a winit app's
|
||||||
|
existing code is unaffected. An Android app implements `FocusHost` via
|
||||||
|
`HasAndroidUiState` instead. Affects only an app that referenced
|
||||||
|
`HasDefaultUiState` directly at a `Selectable`/`Selector` call site
|
||||||
|
rather than through `.attr::<Selectable>(())`, which nothing in-tree
|
||||||
|
does.
|
||||||
|
- **`Tasks::init` takes `Arc<dyn RequestRedraw>` instead of
|
||||||
|
`Arc<winit::window::Window>`.** `RequestRedraw` (`task.rs`) is one method,
|
||||||
|
`fn request_redraw(&self)`; `winit::window::Window` implements it
|
||||||
|
(`default/render.rs`), so `Tasks::init(window)` at a call site is
|
||||||
|
unchanged by inference. Only matters if something constructed a `Tasks`
|
||||||
|
directly rather than through `DefaultRsc`/`AndroidRsc`.
|
||||||
|
- **`TextEdit::apply_event`/`TextInputResult` are `#[cfg(not(target_os =
|
||||||
|
"android"))]`** — they take a `winit::event::KeyEvent`, which does not
|
||||||
|
exist on Android; `android/input.rs` drives the same primitives
|
||||||
|
(`backspace`/`delete`/`motion`/`insert`, all still unconditional) from
|
||||||
|
`ndk::event::Keycode` directly instead. New unconditional getters on the
|
||||||
|
way: `TextEdit::text()`/`selection_range()`/`caret()`, and
|
||||||
|
`TextEditCtx::delete_byte_range`/`set_cursor_byte` — the primitives
|
||||||
|
`android/ime.rs`'s `InputConnection` bridge needed and that were not
|
||||||
|
previously exposed publicly.
|
||||||
|
|
||||||
|
## 2026-09-04: `Widget::draw` reports the size it used; `desired_width`/`desired_height` are gone
|
||||||
|
|
||||||
|
A widget used to implement three methods (`draw`, `desired_width`,
|
||||||
|
`desired_height`); it now implements one, `fn draw(&mut self, painter: &mut
|
||||||
|
Painter) -> Size`, which draws into `painter.region()` and returns how much
|
||||||
|
of it was used. Why: the two extra methods routinely re-simulated what
|
||||||
|
`draw` was about to do anyway (`Span::desired_ortho` copied its own draw
|
||||||
|
loop to get cross-axis sizing right) — one visit per widget per frame
|
||||||
|
instead of up to three. A container that needs a child's size before
|
||||||
|
placing it (alignment, centering) draws the child once at a provisional
|
||||||
|
region, reads the returned `Size`, and calls the new `Painter::reposition`
|
||||||
|
to move it into its final spot — an O(1) offset write, not a second draw. A
|
||||||
|
widget whose drawn output never depends on the size it's given (a
|
||||||
|
fixed-size `Rect`, a decoded `Image`) overrides the new `fn
|
||||||
|
is_size_independent(&self) -> bool { false }` to `true`, which skips
|
||||||
|
redrawing it when only its offered region changes shape.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
||||||
|
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||||
|
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||||
|
|
||||||
|
// after
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
`SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
||||||
|
design, the move-offset mechanism this shipped alongside, and the file
|
||||||
|
list.
|
||||||
|
|
||||||
|
## 2026-09-04: texture pipeline rebuilt off the binding array
|
||||||
|
|
||||||
|
`Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all
|
||||||
|
changed shape. Why: the old pipeline bound every texture ever drawn in one
|
||||||
|
`binding_array<texture_2d<f32>>` and asked every device, unconditionally,
|
||||||
|
for `VK_EXT_descriptor_indexing` — a real share of Android GPUs lack it,
|
||||||
|
and it failed outright on the Android emulator's software Vulkan. See
|
||||||
|
TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04".
|
||||||
|
|
||||||
|
- **`UiRenderNode::new` drops its `limits: UiLimits` parameter, and
|
||||||
|
`UiLimits` is gone.** Before: `UiRenderNode::new(&device, &queue,
|
||||||
|
&config, UiLimits::default())`. After: `UiRenderNode::new(&device,
|
||||||
|
&queue, &config)`. Nothing replaces it — there are no more
|
||||||
|
binding-array limits to size.
|
||||||
|
- **`src/default/render.rs`'s device request asks for no features and no
|
||||||
|
binding-array limits.** Before: `required_features:
|
||||||
|
Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY
|
||||||
|
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`
|
||||||
|
plus two `max_binding_array_*` limits. After: `Features::empty()` (the
|
||||||
|
`DeviceDescriptor` default) and only `max_buffer_size` set, which was
|
||||||
|
never about the binding array.
|
||||||
|
- **`TextureHandle` has no `primitive()` method any more**; a caller
|
||||||
|
outside `iris` shouldn't have been calling it (it fed the old renderer's
|
||||||
|
internals), but if something did: use `image_index()` for a standalone
|
||||||
|
image's bind-group index. There is no equivalent for a page — a page has
|
||||||
|
no bind group of its own now, see below.
|
||||||
|
- **`GlyphPrimitive` has no public constructor from a struct literal.**
|
||||||
|
Before: `GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color,
|
||||||
|
flags }`. After: `GlyphPrimitive::new(uv_min, uv_max, layer, color,
|
||||||
|
flags)` — one `layer` (the shared atlas array's layer) instead of a
|
||||||
|
`view_idx`/`sampler_idx` pair, since a page is now a layer of one array
|
||||||
|
texture rather than its own bound texture.
|
||||||
|
- **A widget author drawing images is unaffected**: `Painter::texture`/
|
||||||
|
`texture_at`/`texture_within` and `Textures::add` keep their signatures.
|
||||||
|
What changed underneath is that each standalone image now gets its own
|
||||||
|
`wgpu::BindGroup` and draw call instead of a slot in the shared array —
|
||||||
|
invisible from the widget API, visible only in `UiRenderNode`'s internals
|
||||||
|
and in `iris`'s device requirements.
|
||||||
|
|
||||||
|
## 2026-09-05: `FrameReport` splits each frame at `queue.submit`
|
||||||
|
|
||||||
|
`FrameStats` gains two fields, and `FrameReport` gains a second recording
|
||||||
|
method, to answer "is a slow frame iris's own CPU work or the driver/GPU"
|
||||||
|
with a number instead of a guess (RUST.md's I5 box).
|
||||||
|
|
||||||
|
- **`FrameReport::record_split(total, submit_to_present)`** is a second way
|
||||||
|
to record a frame, alongside the existing `record(total)` (unchanged,
|
||||||
|
and still what a caller with no split should use — it now reads as
|
||||||
|
`cpu_p50 == total`, `gpu_wait_p50 == 0`, rather than fabricating a
|
||||||
|
number for a half it never measured).
|
||||||
|
- **`FrameStats` gains `cpu_p50` and `gpu_wait_p50`**: medians of
|
||||||
|
redraw-start-to-submit and submit-to-after-`present()` respectively,
|
||||||
|
independent of each other and of the existing `p50`/`p90`/`p99`/`worst`
|
||||||
|
(which are unchanged, and still over the whole frame). The Android
|
||||||
|
renderer's `draw()` now returns the `submit_to_present` `Duration` it
|
||||||
|
measured, which `android::view::render()` passes to `record_split`.
|
||||||
|
- **Caveat carried in both doc comments**: `submit_to_present` is not
|
||||||
|
fenced against the GPU actually finishing — it is "how long the CPU was
|
||||||
|
blocked handing the frame to the driver," not a confirmed GPU-completion
|
||||||
|
time. Enough to separate "iris is slow building the frame" from "iris is
|
||||||
|
slow handing it off," not enough to claim an exact GPU budget.
|
||||||
|
|
||||||
|
## 2026-09-05: `List::replace_back`/`List::clear`, and `TranscriptScreen::apply`
|
||||||
|
|
||||||
|
Fixes the "every client refolds and rebuilds the whole widget tree per
|
||||||
|
streamed event" cost RUST.md's P0 box measured (20 events/second against a
|
||||||
|
~3,200-row transcript). Two small additions to `iris::widget::List`
|
||||||
|
(`iris/src/widget/list.rs`), plus one new method on `transcript-ui`'s
|
||||||
|
`TranscriptScreen`.
|
||||||
|
|
||||||
|
- **`List::replace_back(row: ListRow) -> Option<ListRow>`**: swaps the
|
||||||
|
*last* row's widget for a new one without moving it — same slot index,
|
||||||
|
so an anchor already pinned there (in particular a list flush with its
|
||||||
|
own end) stays pinned, and a `List` scrolled elsewhere is untouched.
|
||||||
|
`None` if the list is empty. `RowKey` may differ between the old and new
|
||||||
|
row; only `heights`/`extents` care, and both are invalidated for the
|
||||||
|
evicted key the same way `pop_back` already does.
|
||||||
|
- **`List::clear()`**: drops every loaded row and resets to `List::new`'s
|
||||||
|
state (`more_before`/`more_after` untouched — a caller that wants those
|
||||||
|
cleared too calls `set_more_before(None)`/`set_more_after(None)` itself).
|
||||||
|
The fallback path for a change that touches more than the tail.
|
||||||
|
- **`transcript_ui::TranscriptScreen::apply(&self, rsc, old: &[TranscriptItem], new: &[TranscriptItem])`**:
|
||||||
|
the incremental alternative to rebuilding the whole screen from
|
||||||
|
`transcript_ui::build_tree` on every folded event. Diffs the two
|
||||||
|
`group_tool_runs` outputs and picks the cheapest update: nothing changed
|
||||||
|
(no-op), a pure append (`push_row`, unchanged cost), or — the common
|
||||||
|
streaming case, a delta into a still-open assistant message — a rebuild
|
||||||
|
of just the one changed row via `List::replace_back`, with any further
|
||||||
|
new rows appended after it. A row changing *before* the tail (only
|
||||||
|
`group_tool_runs` retroactively grouping tool calls into a run does
|
||||||
|
this) falls back to `List::clear` plus a full rebuild, counted in
|
||||||
|
`TranscriptScreen::take_rebuilds()`. `bench_client.rs`, `transcript_client.rs`
|
||||||
|
and `desktop-app/app.rs` all call this now instead of rebuilding on every
|
||||||
|
event; only the opening page (and `apply`'s own fallback) still calls
|
||||||
|
`build_tree`.
|
||||||
|
- **`TextEditCtx::set_with_spans(text, spans)`**: `set()` plus a fresh
|
||||||
|
`Vec<SpanStyle>` in one call, needed because a streamed row's markdown
|
||||||
|
re-renders to both a new string and a new span list on every delta and
|
||||||
|
the two have to land together — a stale span list drawn against new
|
||||||
|
text can point past its end. `set()` itself is unchanged (still clears
|
||||||
|
spans to none, as before).
|
||||||
|
|
||||||
|
Measured on this checkout's emulator (`iris/android-app/run-bench.sh`,
|
||||||
|
release, x86_64, `force-gles`): worst-frame and p99 during the streaming
|
||||||
|
phase dropped from 369.3ms/284.5ms (full rebuild per event, prior pass) to
|
||||||
|
~101–130ms/~76–103ms across three runs (this fix) — see RUST.md's P0 box
|
||||||
|
for the full numbers and the comparison's caveats (different AVD
|
||||||
|
instances, not a controlled A/B on identical hardware state).
|
||||||
|
|
||||||
|
## 2026-09-06: bundled fonts, `content_scale`, `AndroidAppState::on_insets_changed`
|
||||||
|
|
||||||
|
From RUST.md's P0 box, working Iris's first real-phone report (font/scale/
|
||||||
|
inset bugs the emulator never showed).
|
||||||
|
|
||||||
|
- **`TextData` now bundles Noto Sans + Noto Sans Mono** (regular/bold/
|
||||||
|
italic/bold-italic static faces, OFL) and registers them ahead of the
|
||||||
|
platform's own fonts in the `SansSerif`/`Monospace` generic-family
|
||||||
|
lists, rather than relying on the platform's font enumeration alone.
|
||||||
|
`TextData::font_diagnostics() -> FontDiagnostics` reports what was found
|
||||||
|
and what each style axis resolved to — logged once at startup and shown
|
||||||
|
on a screen's Diagnostics page if it has one. Adds ~3.6 MB uncompressed
|
||||||
|
to any binary linking `iris-core`; `build-apk.sh`'s own output says the
|
||||||
|
delivered (compressed) number.
|
||||||
|
- **`UiRenderNode::new`/`resize` now take the window size explicitly**
|
||||||
|
(`window_size: impl Into<Vec2>`) instead of deriving it from the
|
||||||
|
surface's physical `SurfaceConfiguration`. Existing callers pass a
|
||||||
|
*logical* size (physical ÷ density/scale-factor) now; this is what makes
|
||||||
|
a `font_size: 16.0` 16 dp instead of 16 raw device pixels on a
|
||||||
|
high-density phone. Before this, `scale_factor` did not exist anywhere
|
||||||
|
in the crate, on either platform.
|
||||||
|
- **`AndroidUiState::content_scale: f32`** (`DisplayMetrics.density`, read
|
||||||
|
once in `new_peer`) and the desktop equivalent (`window.scale_factor()`)
|
||||||
|
now divide every physical-pixel number before it reaches layout or
|
||||||
|
touch handling — see `content_scale`'s own field doc for the full list
|
||||||
|
of what depends on it.
|
||||||
|
- **New: `AndroidAppState::on_insets_changed(&mut self, rsc, LogicalInsets)`**,
|
||||||
|
a default-no-op hook called from `render()` exactly when
|
||||||
|
`AndroidUiState::insets()` changes. Nothing previously consumed
|
||||||
|
`insets().top` at all; a screen with chrome under the status bar
|
||||||
|
implements this to pad it, in the same logical units `content_scale`
|
||||||
|
converts everything else to.
|
||||||
|
- **New: `iris_core::WgpuErrorLog`**, installed via `Device::
|
||||||
|
on_uncaptured_error` on the Android device (wgpu's default handler is an
|
||||||
|
unconditional panic outside `UiRenderNode::new`'s own error scopes).
|
||||||
|
Explicit `Arc`-backed value passed to the callback and kept on
|
||||||
|
`AndroidRenderer`, not a global — a caller wanting one on desktop builds
|
||||||
|
its own the same way.
|
||||||
|
|
||||||
|
## 2026-09-06: `Len::dp`, physical pixels throughout, the keyboard glyph wipe
|
||||||
|
|
||||||
|
Iris's phone report on build a9232ac (screenshots): text now the right
|
||||||
|
size but blurry; the keyboard still wipes every glyph; the header buttons
|
||||||
|
have nothing behind them. All three are fixed; this entry is the public
|
||||||
|
API side. docs/LAYOUT.md has the layout-side writeup, docs/RUST.md's P0
|
||||||
|
box has the full investigation and the phone verification still to do.
|
||||||
|
|
||||||
|
- **The keyboard wipe was `surface_changed` rebuilding the whole renderer
|
||||||
|
on every resize**, including an IME-driven one — a fresh, empty glyph
|
||||||
|
atlas while the CPU-side glyph cache kept UV coordinates from the old
|
||||||
|
one. `surface_changed` now calls `AndroidRenderer::resize` (reconfigures
|
||||||
|
the surface and window uniform only) when a renderer is already live,
|
||||||
|
and only builds a new one when there genuinely isn't one yet.
|
||||||
|
- **`Len` has a third field, `dp`** (Android's dp / CSS's reference pixel,
|
||||||
|
1/160in), beside the existing `abs` (now explicitly *physical* pixels)
|
||||||
|
and `rel`/`rest`. `len_fns::dp`/`Len::dp` construct one, used exactly
|
||||||
|
like `abs`/`rel`/`rest` — `dp(16)` instead of a bare `16` wherever a
|
||||||
|
size should look the same physical size on any density. This is the
|
||||||
|
unit IRIS_TODO.md's "density-independent length unit" item asked for;
|
||||||
|
it replaces the previous stopgap (the whole rendered scene divided by
|
||||||
|
`content_scale` then implicitly stretched back up), which is also what
|
||||||
|
made text blurry — a glyph rasterised at the small, pre-stretch size and
|
||||||
|
then upscaled onto the real framebuffer.
|
||||||
|
- **`UiRenderState`/`Painter` gained `density()`/`set_density()`** (physical
|
||||||
|
pixels per dp). Every place a length resolves (`Len::apply_rest`,
|
||||||
|
`Size::to_uivec2`) now takes it; `Span::gap` and `Padding`'s four sides
|
||||||
|
moved from a bare `f32` to `Len` so they take `dp(...)` too. A bare
|
||||||
|
number anywhere is unaffected — still `abs`, physical pixels.
|
||||||
|
- **Text is rasterised at physical resolution now.** `TextBuffer::shape`
|
||||||
|
takes `density` and multiplies `font_size`/`line_height` (and any span
|
||||||
|
override) by it before handing them to parley, so the atlas holds a
|
||||||
|
bitmap at the size it is actually shown at rather than a low-resolution
|
||||||
|
one stretched afterward.
|
||||||
|
- **Everything at the Android boundary is physical pixels now** — window
|
||||||
|
size, touch coordinates, insets (`LogicalInsets` renamed
|
||||||
|
`WindowInsets`). The previous "logical" division by `content_scale` is
|
||||||
|
gone; `content_scale` now feeds `set_density` instead.
|
||||||
|
- Not yet verified on Iris's actual phone (this pass had no device) —
|
||||||
|
built and checked on this checkout's emulator only. RUST.md's P0 box
|
||||||
|
says what she should check for: crisp text at two densities, the
|
||||||
|
keyboard no longer wiping, and the header's background.
|
||||||
|
|
||||||
|
## 2026-09-06: composing text, focus-on-tap, and atlas invalidation on a new renderer
|
||||||
|
|
||||||
|
Three small but public API changes, from the same phone-report pass as the
|
||||||
|
entry above (RUST.md's P0 box has the full account, including a real bug
|
||||||
|
still not root-caused).
|
||||||
|
|
||||||
|
- **`FocusHost` gained `is_focused(&self, id) -> bool`** (both platform
|
||||||
|
impls). `attr.rs`'s `Selector`/`Selectable` used to grant focus (and so
|
||||||
|
request the IME) on the very first frame of *any* press, before it was
|
||||||
|
known whether the gesture was a tap or a drag — a swipe over a text
|
||||||
|
field wrongly summoned the keyboard. They now wait for a completed tap
|
||||||
|
(press and release with no frame crossing `sense::DRAG_SLOP`) unless the
|
||||||
|
field is already focused, in which case dragging inside it to select
|
||||||
|
text is unchanged. `TextEdit` gained one new `pub(crate)` field
|
||||||
|
(`press_origin`) to track this; no public surface change there.
|
||||||
|
- **`android::ime`'s `InputConnection` now calls `InputMethodManager::
|
||||||
|
updateSelection` after every edit** (`IrisViewPeer::update_ime_selection`,
|
||||||
|
called from `after_input`). Gboard was holding keystrokes back because
|
||||||
|
nothing ever told it where the app's own selection/composing region had
|
||||||
|
moved to — this is what android-view's own demo does in its `render()`
|
||||||
|
and this bridge never did.
|
||||||
|
- **`GlyphAtlas::clear()` and `Textures::reset()`** (`iris_core`). Called
|
||||||
|
together, once, from `android::view`'s `surface_changed` exactly when a
|
||||||
|
*genuinely new* `AndroidRenderer` is built (backgrounding and returning,
|
||||||
|
not a keyboard-triggered resize, which already reuses the renderer) —
|
||||||
|
both CPU-side caches otherwise kept pointing at the old, now-destroyed
|
||||||
|
device's textures, which is why text used to vanish again after leaving
|
||||||
|
and returning to the app.
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
# iris: known problems and things still to build
|
||||||
|
|
||||||
|
Iris's own list for the library, recorded 2026-09-04 in her words where it
|
||||||
|
matters, so the agents working through RUST.md pick these up in a sensible
|
||||||
|
order rather than rediscovering them. Each item says where it sits in the
|
||||||
|
order and what "done" looks like. Tick and date them in place.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
- [x] **`request_device` asked for compute-shader limits it never uses
|
||||||
|
(2026-09-05).** `Limits::default()` (both `iris/src/android/render.rs`
|
||||||
|
and `iris/src/default/render.rs`) requests desktop-tier compute limits
|
||||||
|
unconditionally, even though nothing in `iris`/`iris-core` creates a
|
||||||
|
`ComputePipeline` or writes a `@compute` shader stage — confirmed by
|
||||||
|
grepping the whole tree, not assumed. That crashed device creation
|
||||||
|
outright on the Android emulator's software GL path (`EMU_GPU=software`,
|
||||||
|
`--features force-gles`): SwiftShader's GL reports itself as OpenGL ES
|
||||||
|
3.0, which has no compute shaders, so the adapter's real limit is 0
|
||||||
|
against the unconditional request for 65535 — the same would happen on
|
||||||
|
any real GLES-3.0-only Android device. Fixed by a new, shared
|
||||||
|
`iris_core::device_limits()` (`iris/core/src/render/mod.rs`) that zeros
|
||||||
|
exactly the six `max_compute_*` fields rather than switching to a
|
||||||
|
downlevel `Limits` preset — `downlevel_webgl2_defaults()` also zeros
|
||||||
|
`max_storage_buffers_per_shader_stage`, which `shader.wgsl`'s vertex
|
||||||
|
stage needs (four `var<storage>` buffers), so that preset would trade
|
||||||
|
this crash for a bind-group-layout one on the same hardware.
|
||||||
|
`rigs/gpu-probe`'s own hand-mirrored `Limits` (it is deliberately its
|
||||||
|
own crate, not able to call `device_limits()` directly) was updated to
|
||||||
|
match. See `DECISIONS.md` and RUST.md's I5 box for the account,
|
||||||
|
including what could not be re-verified on-device this pass (the
|
||||||
|
emulator was in concurrent use by another session).
|
||||||
|
|
||||||
|
- [x] **Input does not fall through by input type (2026-09-04).**
|
||||||
|
`SensorUi::run_sensors` (`src/default/sense.rs`) used to set "consumed,
|
||||||
|
stop checking lower layers" from mere hover — a widget registered for
|
||||||
|
nothing but `click()` blocked a `Scroll` meant for whatever was behind
|
||||||
|
it, since "the cursor is over this widget" and "this widget handled the
|
||||||
|
event" were the same check. Fixed by judging consumption per input
|
||||||
|
kind: with no button transition and no scroll happening this frame
|
||||||
|
("momentary" activity), the topmost hovered widget still wins, same as
|
||||||
|
before; when something momentary *is* happening, only a widget whose
|
||||||
|
registered senses actually include a matching non-hover one (checked
|
||||||
|
via a new `TypeEventManager::registered`, which lists what a widget
|
||||||
|
registered without running anything) consumes it, so a widget with only
|
||||||
|
`Hovering`/click handlers can no longer block a scroll from reaching a
|
||||||
|
list underneath. `iris/src/sense_tests.rs` builds a button-over-a-list
|
||||||
|
`Stack` with a plain `HasEvents` impl (no GPU or window) and checks both
|
||||||
|
directions: a scroll over the button reaches the list, and a real click
|
||||||
|
still reaches the button — confirmed to fail on the pre-fix code and
|
||||||
|
pass after.
|
||||||
|
|
||||||
|
- [x] **Appending one image to an already-loaded list rebuilds every other
|
||||||
|
image's bind group (2026-09-05, fixed 2026-09-05).** Found by the
|
||||||
|
benchmark below: `GpuTextures::update` (`core/src/render/texture.rs`)
|
||||||
|
triggered `rebuild_image_bind_groups` — a loop over *every live
|
||||||
|
standalone image*, rebuilding its `BindGroup` — whenever the shared
|
||||||
|
`masks` or `move_offsets` GPU buffer was resized (`masks_resized ||
|
||||||
|
moves_resized` in `UiRenderNode::update`, `core/src/render/mod.rs`), and
|
||||||
|
a widget getting its *first* move-offset slot (LAYOUT.md section 2 —
|
||||||
|
every widget gets one on first draw) could be exactly what grows that
|
||||||
|
buffer. So one new message with one new image, appended to a transcript
|
||||||
|
that already has N images loaded, did not cost O(1): it cost one
|
||||||
|
`create_image` for the new image plus one `make_image_bind_group` per
|
||||||
|
*existing* image, because the new widget's own move slot pushed the
|
||||||
|
arena past its capacity. Measured directly in
|
||||||
|
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
|
||||||
|
already-settled ones reported **1,001** bind-group creates for that one
|
||||||
|
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
|
||||||
|
|
||||||
|
**Fix**: `masks`/`move_offsets` never belonged in a standalone image's own
|
||||||
|
bind group (group 2) in the first place — the group also holds that
|
||||||
|
image's own texture view, which is the only thing that is genuinely
|
||||||
|
per-image, so a buffer shared by *everything* forced a rebuild of
|
||||||
|
*every* group the moment it moved. Gave masks/move_offsets their own
|
||||||
|
bind group (group 3 in `shader.wgsl` and `UiRenderNode`: `masks_layout`/
|
||||||
|
`masks_group`), bound once per frame in `UiRenderNode::draw` rather than
|
||||||
|
once per draw call, instead of duplicating them into every per-image
|
||||||
|
group. `GpuTextures` and its image bind groups now know nothing about
|
||||||
|
either buffer — `rebuild_image_bind_groups` is called only from
|
||||||
|
`grow_array` (the atlas array texture growing, which genuinely does
|
||||||
|
change what every image's own bind group must reference) — so a
|
||||||
|
masks/move_offsets resize now touches exactly one bind group, ever,
|
||||||
|
regardless of how many images are live. Numbers after the fix, same
|
||||||
|
benchmark and command:
|
||||||
|
|
||||||
|
./run-bench.sh images
|
||||||
|
frame=1 bind_group_creates=1000 (cold load, unchanged)
|
||||||
|
frame=2 bind_group_creates=0 (was 1000 -- see the item below)
|
||||||
|
frame=3 bind_group_creates=0
|
||||||
|
frame=4 bind_group_creates=0
|
||||||
|
(append one image here)
|
||||||
|
frame=5 bind_group_creates=1 (was 1001)
|
||||||
|
frame=6 bind_group_creates=0
|
||||||
|
|
||||||
|
`run-headless.sh tabs --shot` still 27266 bytes, byte-for-byte unchanged,
|
||||||
|
confirming the bind-group restructuring changed nothing about what is
|
||||||
|
drawn.
|
||||||
|
- [x] **Bind-group creation takes two frames to reach the steady state, not
|
||||||
|
one (2026-09-05, closed by the fix above, 2026-09-05).** Same benchmark:
|
||||||
|
loading 1,000 images cold used to report 1,000 creates on frame 1
|
||||||
|
(expected — `create_image`, one per new image) *and again* 1,000 on
|
||||||
|
frame 2, before settling to 0 from frame 3. This was `rebuild_image_bind_groups`
|
||||||
|
firing a second time for the same masks/move-offsets buffer-growth
|
||||||
|
reason as the item above, confirming the guess recorded here — the two
|
||||||
|
were exactly the same root cause measured two different ways. Frame 2
|
||||||
|
now reports 0 (see the numbers above); not a separate fix.
|
||||||
|
|
||||||
|
- [ ] **A read-only text display has no widget of its own — P0's bench
|
||||||
|
report area is a `TextEdit` standing in for one (2026-09-05).** The only
|
||||||
|
way to get selectable text on screen today is `.editable(...)` plus
|
||||||
|
`.attr::<Selectable>(())` (`Selectable` is only implemented for
|
||||||
|
`TextEdit`, `iris/src/attr.rs`), which also makes the field focusable —
|
||||||
|
tapping the bench report opens the soft keyboard over text nothing lets
|
||||||
|
you type into. Harmless for a bench-only debug screen (not fixed this
|
||||||
|
pass), but a real "selectable, not editable" text primitive would
|
||||||
|
remove the keyboard side effect and is worth having before another
|
||||||
|
screen wants the same thing (P1's own transcript rows already read
|
||||||
|
their content from a `TextEdit` for the same reason).
|
||||||
|
|
||||||
|
## From the phone, 2026-09-06
|
||||||
|
|
||||||
|
Found on Iris's own phone while working RUST.md's P0 box's phone-report
|
||||||
|
follow-ups. Recorded here rather than fixed in that pass, so a follow-up
|
||||||
|
agent takes them without colliding with that pass's `bench_client.rs`/
|
||||||
|
`android/view.rs`/`android/sense.rs` changes.
|
||||||
|
|
||||||
|
- [x] **Swiping has no momentum, fixed 2026-09-06.** `List::fling`/
|
||||||
|
`VelocityTracker`/`FlingCalculator` (`iris/src/widget/list.rs`,
|
||||||
|
`iris/src/sense.rs`) -- IRIS.md's 2026-09-06 entry has the full account.
|
||||||
|
Wired through `Selection::drag`'s release path, cancelled by the next
|
||||||
|
touch-down, clamped at the loaded content's start/end. Verified by unit
|
||||||
|
test (fling distance against the closed-form spline result, cancel-on-
|
||||||
|
touch, the clamp), not yet by an on-device or emulator feel-check --
|
||||||
|
that is still open.
|
||||||
|
- [x] **Scrolling down sometimes jitters the text, fixed 2026-09-06.**
|
||||||
|
Root-caused by reading `DragArbiter::update`'s `Undecided`-to-`Panning`
|
||||||
|
transition rather than by an on-device trace (no emulator was used this
|
||||||
|
pass): it was the first named suspect, not the second. `self.last` stays
|
||||||
|
at the press origin for every `Undecided` frame (nothing pans while the
|
||||||
|
gesture might still be a selection), so the frame that finally crosses
|
||||||
|
`DRAG_SLOP` returned `Pan(dy)` with `dy` measured from `press_start` --
|
||||||
|
the *whole* pre-threshold drag, applied to the list in one step, however
|
||||||
|
many frames it had taken to get there. Fixed by applying only the
|
||||||
|
excess past `DRAG_SLOP` on that one frame (`dy - DRAG_SLOP.copysign
|
||||||
|
(dy)`), the same "consume the slop, don't replay it" rule Android's own
|
||||||
|
touch handling follows. New regression test,
|
||||||
|
`crossing_the_slop_by_a_little_pans_by_a_little` (`iris/src/sense.rs`).
|
||||||
|
**Not yet done**: an emulator trace of the real per-frame offset
|
||||||
|
confirming this was the whole story on real touch input rather than
|
||||||
|
only the arbiter's own unit tests -- worth a follow-up pass before
|
||||||
|
calling it fully closed.
|
||||||
|
- [x] **Composing text held back until a space, caret not moving, fixed
|
||||||
|
2026-09-06.** `InputMethodManager.updateSelection` was never called --
|
||||||
|
see IRIS.md's 2026-09-06 entry and RUST.md's P0 box, item 1, for the
|
||||||
|
full account and the emulator evidence.
|
||||||
|
- [x] **Swipe over the composer summons the keyboard, fixed 2026-09-06.**
|
||||||
|
`Selector`/`Selectable` now wait for a completed tap -- see IRIS.md's
|
||||||
|
2026-09-06 entry and RUST.md's P0 box, item 5. Verified via `dumpsys
|
||||||
|
input_method`'s `mInputShown` on the emulator, not yet on the phone.
|
||||||
|
- [x] **Text disappears again after leaving and returning to the app,
|
||||||
|
fixed 2026-09-06.** `GlyphAtlas::clear`/`Textures::reset` on a
|
||||||
|
genuinely new renderer -- see IRIS.md's 2026-09-06 entry and RUST.md's
|
||||||
|
P0 box, item 4. Verified on the emulator (home, reopen, screenshot);
|
||||||
|
not yet on the phone.
|
||||||
|
- [ ] **Composed/typed text never becomes visible at all -- found
|
||||||
|
2026-09-06, not fixed.** The composer bar stays empty even once the
|
||||||
|
buffer genuinely holds the typed text (confirmed indirectly: Gboard's
|
||||||
|
own suggestion strip reacts correctly to each keystroke). A new unit
|
||||||
|
test proves the widget tree's own layout math resolves the field's
|
||||||
|
region correctly across a keyboard resize, so the bug is downstream of
|
||||||
|
that -- most likely `UiRenderState::redraw`'s single-widget redraw path,
|
||||||
|
or specific to this emulator's forced `force-gles` backend (untested on
|
||||||
|
Vulkan or the real phone). RUST.md's P0 box, item 2, has the full
|
||||||
|
writeup, what was ruled out, and where to look next. **Also unverified
|
||||||
|
because of this**: item 3's composer rebuild (one `Stack`-based widget,
|
||||||
|
a capped/scrollable height, bottom padding tied to the IME/nav-bar
|
||||||
|
inset) -- structurally in place and unit-tested, but its own visual
|
||||||
|
correctness cannot be screenshotted until text actually renders.
|
||||||
|
- [ ] **The composer has no touch-drag scroll for overflowing text.** The
|
||||||
|
2026-09-06 rebuild caps the field at ~6 lines and wraps it in
|
||||||
|
`.scrollable()` for a wheel/trackpad scroll, but a real finger drag over
|
||||||
|
text that has overflowed the cap does not scroll it -- `Scroll`'s touch
|
||||||
|
handling is a follow-up, the same shape `List`'s own touch-drag pan
|
||||||
|
needed before I3/I5.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
||||||
|
`benches/` or a script under `iris/`, never in `cargo test`). The
|
||||||
|
scenario that matters most is a **message list** — chat apps and this
|
||||||
|
app's transcript alike — stressed with many messages and many images.
|
||||||
|
One case in particular: **resizing an input box** (typing enough text to
|
||||||
|
grow it) that pushes a long list of messages above it must stay very
|
||||||
|
fast and recalculate almost nothing — a move of everything above, not a
|
||||||
|
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
|
||||||
|
benchmark is what proves it. Done when the numbers are in this file with
|
||||||
|
the command, and the input-box case reports draws re-run, not just frame
|
||||||
|
time.
|
||||||
|
|
||||||
|
**Built as two rigs**, chosen per scenario by whether a real `wgpu`
|
||||||
|
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
|
||||||
|
most of this runs as an ordinary binary — the same property
|
||||||
|
`layout_tests.rs` relies on):
|
||||||
|
|
||||||
|
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
|
||||||
|
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
|
||||||
|
the file's own header for why (short version — every scenario here
|
||||||
|
reduces to a *count* `UiRenderState::take_counters` already produces,
|
||||||
|
which criterion's statistical machinery adds nothing to and which a
|
||||||
|
new dependency is not worth pulling in for). Covers (a) first-frame
|
||||||
|
cost of a message list of N wrapped-text rows (one in 20 also carrying
|
||||||
|
a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost
|
||||||
|
of scrolling that list, 200 ticks; (c) the input-box case — a
|
||||||
|
fixed-height field at the bottom of the screen growing by a line 40
|
||||||
|
times, with the message list above it filling the rest of the screen.
|
||||||
|
Run: `cd iris && cargo bench --bench message_list` (always release —
|
||||||
|
`cargo bench` builds the `bench` profile, which is optimized).
|
||||||
|
- `iris/examples/bench_images.rs` — needs a real device, so it runs
|
||||||
|
through `iris/run-headless.sh bench_images`, printing
|
||||||
|
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
|
||||||
|
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
|
||||||
|
mirroring `UiRenderState::take_counters`) each frame. Covers (d): 1,000
|
||||||
|
image rows, checked both cold (does bind-group creation reach zero
|
||||||
|
once loaded) and after appending one more image once settled (does
|
||||||
|
*that* stay cheap) — the second question is what actually matters for
|
||||||
|
a live transcript and is what turned up the two Fix items above.
|
||||||
|
- `iris/run-bench.sh [list|images]` runs either or both and is what to
|
||||||
|
run before/after touching `Scroll`, `Span`, `Sized`, the move-offset
|
||||||
|
chain, or `GpuTextures`.
|
||||||
|
|
||||||
|
**Numbers (2026-09-05, release, `cargo bench`/`run-headless.sh`, this
|
||||||
|
VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):**
|
||||||
|
|
||||||
|
cd iris && cargo bench --bench message_list
|
||||||
|
(a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0
|
||||||
|
(a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0
|
||||||
|
(a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0
|
||||||
|
(b) scroll, N=100/1000/10000, 200 ticks each:
|
||||||
|
draws=200 rewrites=0 moves=200 (identical at every N)
|
||||||
|
per-tick average: 0.0002ms (identical at every N)
|
||||||
|
(c) input grows 40 lines, N=100/1000/10000 rows above it:
|
||||||
|
draws=320 rewrites=40 moves=160 (identical at every N)
|
||||||
|
per-line average: 0.0012-0.0013ms (identical at every N)
|
||||||
|
|
||||||
|
cd iris && ./run-bench.sh images (2026-09-05, before the fix)
|
||||||
|
frame=1 bind_group_creates=1000 (cold load)
|
||||||
|
frame=2 bind_group_creates=1000 (see Fix item above)
|
||||||
|
frame=3 bind_group_creates=0
|
||||||
|
frame=4 bind_group_creates=0
|
||||||
|
(append one image here)
|
||||||
|
frame=5 bind_group_creates=1001 (see Fix item above)
|
||||||
|
frame=6 bind_group_creates=0
|
||||||
|
|
||||||
|
cd iris && ./run-bench.sh images (2026-09-05, after the fix)
|
||||||
|
frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work)
|
||||||
|
frame=2 bind_group_creates=0
|
||||||
|
frame=3 bind_group_creates=0
|
||||||
|
frame=4 bind_group_creates=0
|
||||||
|
(append one image here)
|
||||||
|
frame=5 bind_group_creates=1 (one image's own create_image, O(1))
|
||||||
|
frame=6 bind_group_creates=0
|
||||||
|
|
||||||
|
**Reading it**: (a) is real, necessary work — shaping and laying out N
|
||||||
|
never-before-seen text rows — and scales with N as it must, ~10x cost
|
||||||
|
per 10x N. (b) and (c) are the pass conditions that matter: both are
|
||||||
|
**exactly flat across N = 100 to 10,000**, confirming LAYOUT.md's O(1)
|
||||||
|
move chain holds for both scrolling and for a growing input box pushing
|
||||||
|
the message list — draws/moves per tick or per line do not grow with
|
||||||
|
list size, and the per-operation cost (a fraction of a microsecond) is
|
||||||
|
nowhere near a frame budget. (d)'s cold-load and steady-state halves
|
||||||
|
behave as designed; its *append* half did not, until the fix above moved
|
||||||
|
masks/move_offsets out of the per-image bind group — now flat at O(1)
|
||||||
|
the same way (b) and (c) are.
|
||||||
|
|
||||||
|
- **I5's transcript screen (`iris/transcript-ui/`, 2026-09-05) — what it
|
||||||
|
left, each recorded at the point in the code it would go rather than
|
||||||
|
silently dropped. See RUST.md's I5 box for the full account of what
|
||||||
|
*was* built (the screen, `SpanStyle`, cross-row selection, the growing
|
||||||
|
composer).**
|
||||||
|
- [x] **Android integration for this screen — done, 2026-09-05.**
|
||||||
|
`iris-android-app`'s `transcript-screen` Cargo feature
|
||||||
|
(`transcript_client.rs`) runs this screen against a real `ai-server`
|
||||||
|
through `client-core`, confirmed on-device: real scrolling, real
|
||||||
|
touch-drag panning, tap-by-name on the composer. Two real bugs found
|
||||||
|
and fixed along the way (a missing `INTERNET` permission; a
|
||||||
|
background-thread redraw request that crashed via a `Looper`
|
||||||
|
requirement, fixed by routing through `View::post_delayed` — see
|
||||||
|
`IRIS.md`'s `Tasks::redraw_handle` entry). See RUST.md's I5 box,
|
||||||
|
"The Android integration, done 2026-09-05" for the full account.
|
||||||
|
- [x] **A render-time number for iris, comparable to Compose's
|
||||||
|
`transcript-bench.sh` report — instrumentation done and a real number
|
||||||
|
obtained, 2026-09-05 (later the same day); the clean comparable loop
|
||||||
|
is not.** `iris_core::FrameReport` (`iris/core/src/render/
|
||||||
|
frame_report.rs`, `IRIS.md`'s new entry) times every frame from
|
||||||
|
`render()`'s redraw start to after `queue.submit`+`present()`, exposed
|
||||||
|
as two named on-screen controls ("Frame report", "Reset frame
|
||||||
|
report"). Driven against a real on-device touch-drag it read
|
||||||
|
`frames=34 janky%=61.76 p50=26.5ms p90=48.0ms p99=98.1ms
|
||||||
|
worst=98.1ms` — real, not inferred, but accumulated across several
|
||||||
|
gestures rather than one clean 24-swipe loop, because of the new
|
||||||
|
finding below. See RUST.md's I5 box, "Update, 2026-09-05, later the
|
||||||
|
same day" for the full account.
|
||||||
|
- [ ] **New, 2026-09-05: intermittent touch delivery to iris's
|
||||||
|
`SurfaceView` under this checkout's `EMU_GPU=software` emulator.**
|
||||||
|
The same swipe coordinates, confirmed (by scanning a screenshot
|
||||||
|
column for the first non-black pixel) to sit over real row text,
|
||||||
|
sometimes produced 30+ real frames and a screenshot diff and
|
||||||
|
sometimes produced zero of either, across otherwise-identical
|
||||||
|
`ui-trace` invocations. Not the already-understood "already at that
|
||||||
|
scroll edge" case (reproduced with content confirmed taller than the
|
||||||
|
viewport, in both directions). Leading candidate, not yet confirmed:
|
||||||
|
this checkout's emulator was independently observed at ~78% of one
|
||||||
|
CPU core, continuously, while idle on-screen — SwiftShader's software
|
||||||
|
rasterisation is CPU-bound by design, and a synthetic touch competing
|
||||||
|
with that load for delivery is plausible but unmeasured *during* a
|
||||||
|
failing gesture (the standing rule against diagnosing from
|
||||||
|
after-the-fact measurements applies here). Needs a sampler (load,
|
||||||
|
`dumpsys input`, a `-i 0` `ui-trace` capture) running while a failing
|
||||||
|
gesture is driven, and ideally a comparison under `-gpu host` (real
|
||||||
|
Vulkan) to see whether it is specific to software rendering. This is
|
||||||
|
what blocks the clean, comparable 24-swipe loop above.
|
||||||
|
- [x] **Long-press-then-drag-to-select — confirmed on-device, 2026-09-05
|
||||||
|
(later the same day).** `ui-trace` gained a `holddrag X1 Y1 X2 Y2
|
||||||
|
HOLD_MS MOVE_MS` action (`emulator-tools`, additive, extends the same
|
||||||
|
`MotionEvent`/`injectInputEvent` mechanism `swipe` already used):
|
||||||
|
press, hold past `LONG_PRESS`, move, release, as one continuous touch.
|
||||||
|
Driven against a real row (`holddrag 300 1850 300 2050 600 300`) it
|
||||||
|
produced `iris selection: begin at row ...` then a sequence of
|
||||||
|
`iris selection: extend to row ...` log lines
|
||||||
|
(`transcript-ui/src/selection.rs`, a new small `log` dependency since
|
||||||
|
selection has no accessibility label of its own yet — see the next
|
||||||
|
item), and a screenshot taken right after shows the expected
|
||||||
|
highlighted selection spanning multiple rows. `DragArbiter`'s own
|
||||||
|
unit tests already covered this sequence against a synthetic clock;
|
||||||
|
this is the first time it has been driven by a real device touch.
|
||||||
|
- [x] **Touch-drag panning over a row's own rendered text — done,
|
||||||
|
2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
|
||||||
|
on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
|
||||||
|
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
|
||||||
|
registration won `core/src/sense.rs::run_sensors`'s per-layer
|
||||||
|
arbitration on every frame it was pressed, not just the frame the
|
||||||
|
press started, so a list pan gesture registered on `List` itself never
|
||||||
|
got a turn while a row was under the finger. Fixed with
|
||||||
|
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
|
||||||
|
machine per list deciding pan vs. select the way Android does (a
|
||||||
|
vertical drag pans immediately; a stationary press held `LONG_PRESS`
|
||||||
|
(500ms) starts a selection which further drag extends; a horizontal
|
||||||
|
drag while something is already selected extends immediately) —
|
||||||
|
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
|
||||||
|
every row's drag now routes through. 8 new unit tests
|
||||||
|
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
|
||||||
|
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
|
||||||
|
clean; `run-headless.sh` screenshot byte-identical to before the
|
||||||
|
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
|
||||||
|
- [x] **Intermittent touch-scroll dropout — root-caused and fixed,
|
||||||
|
2026-09-05.** Not the coalesced-`ACTION_MOVE` hypothesis the earlier
|
||||||
|
pass suspected (ruled out): a gesture's `ACTION_DOWN` can land on a
|
||||||
|
row's own padding/gap or its header, which no `CursorSense` covers,
|
||||||
|
so `DragArbiter` never gets `press_start` and sits in `Idle`
|
||||||
|
(answers `Undecided` forever) for that whole gesture. Fixed via a new
|
||||||
|
`DragArbiter::is_idle()` that `Selection::drag`
|
||||||
|
(`transcript-ui/src/selection.rs`) checks to recover a missed press
|
||||||
|
on the next `Pressing` frame. Four new unit tests. See RUST.md's I5
|
||||||
|
box, "Touch-scroll dropout root-caused, 2026-09-05", for the trace and
|
||||||
|
what a peer session sharing this checkout's emulator mid-pass
|
||||||
|
prevented from being re-verified end-to-end (the aggregate
|
||||||
|
`iris-scroll.sh` three-run confirmation and a re-taken FrameReport
|
||||||
|
row) — a future pass should finish that once the emulator is free.
|
||||||
|
- [ ] **Row-level accessibility names.** The composer carries
|
||||||
|
`.label("Message")`; transcript rows do not carry a `.label()` of
|
||||||
|
their own yet, so `Widgets::named()` (I4) does not include them —
|
||||||
|
`row.rs`'s `build_text_row` is where one would go, keyed to something
|
||||||
|
stable per row (its sender + a short excerpt, matching what a screen
|
||||||
|
reader announcing a chat message would say).
|
||||||
|
- [ ] **A tappable link and a background chip behind inline code.**
|
||||||
|
Both need per-range glyph geometry that `TextEditCtx` does not expose
|
||||||
|
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
|
||||||
|
private) — see `markdown.rs`'s module doc for the exact shape the fix
|
||||||
|
would take (the same primitive `TextEdit::draw`'s own selection
|
||||||
|
highlight already uses internally,
|
||||||
|
`iris/src/widget/text/edit.rs:99`).
|
||||||
|
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
||||||
|
is selected in full (`select_all`) the moment the drag leaves it,
|
||||||
|
rather than "from the click point to whichever edge points away from
|
||||||
|
the drag" — needs the same private `layout()` access as the item
|
||||||
|
above. `selection.rs`'s module doc has the exact reasoning.
|
||||||
|
- [ ] **No syntax highlighting inside a fenced code block.**
|
||||||
|
`client_core::highlight` exists (built for the file explorer) and
|
||||||
|
could feed per-token `SpanStyle`s into a code block's span; wiring it
|
||||||
|
in was not attempted this pass.
|
||||||
|
|
||||||
|
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
||||||
|
by something *and also* applies mask B — a mask can reference a parent
|
||||||
|
mask, the way the move chain references a parent offset. Today masks
|
||||||
|
are independent regions. Design it beside the move chain (same shape:
|
||||||
|
a parent index and a bounded walk in the shader); do it when a real
|
||||||
|
widget needs it, not before.
|
||||||
|
- [ ] **Positions as a single float per scroll.** Iris raised, and half
|
||||||
|
rejected, letting a scroll update one float rather than positions:
|
||||||
|
input handling cares about most elements in a list, so absolute
|
||||||
|
positions must be computed on the CPU anyway. LAYOUT.md's design
|
||||||
|
already lands here (GPU walks the chain, CPU resolves on demand for
|
||||||
|
hit tests). Keep the CPU resolution lazy and per query; do not
|
||||||
|
materialise every row's absolute position per frame.
|
||||||
|
- [ ] **Animations, last.** Cosmetic, so after everything above. Must be
|
||||||
|
**modular — a piece of the library rather than a core part forced into
|
||||||
|
everything, the same way input is**. Whatever the mechanism, a widget
|
||||||
|
that does not animate must pay nothing and import nothing for it.
|
||||||
|
|
||||||
|
## Build (for the port)
|
||||||
|
|
||||||
|
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
||||||
|
iris does not have yet, one entry per gap, named against the P-step that
|
||||||
|
first needs it. Move an entry up to "Fix" or tick it in place once built;
|
||||||
|
do not duplicate it there.
|
||||||
|
|
||||||
|
- [ ] **A history-paging cushion measured in on-screen viewports, not a
|
||||||
|
row count.** (**P1**.) `iris::widget::List` has no equivalent of the
|
||||||
|
Compose app's `HISTORY_SCREENS` — AGENTS.md's "Things that have
|
||||||
|
bitten" is explicit that a fixed row count under-fills a screen on a
|
||||||
|
tool-heavy transcript and over-fills one on a text-heavy one, so
|
||||||
|
whatever loads the next page has to ask the list how many viewports
|
||||||
|
are actually on screen, not assume a constant.
|
||||||
|
- [ ] **A scaled thumbnail/image widget for an in-transcript image.**
|
||||||
|
(**P1**.) `SessionImage.kt`'s bitmap decode-and-downscale has no iris
|
||||||
|
counterpart; iris's own image widget (used by `bench_images.rs`) draws
|
||||||
|
a loaded texture but does nothing about sourcing or scaling one from a
|
||||||
|
server-produced attachment.
|
||||||
|
- [ ] **A modal/dialog primitive.** (**P1**, reused by **P3** and
|
||||||
|
**P5**.) Needed for the session settings dialog, `UsageDialog`'s
|
||||||
|
equivalent, and the delete-with-`deleteForeign` confirmation with its
|
||||||
|
toggle switch. Build once, wherever it is first needed, rather than
|
||||||
|
once per screen that wants one.
|
||||||
|
- [ ] **A horizontal gauge/bar widget.** (**P1**.) For
|
||||||
|
`SessionUsageBar`'s equivalent — a bounded fill reflecting a fraction,
|
||||||
|
nothing fancier.
|
||||||
|
- [ ] **A `BusyItem` equivalent: a dimmed row carrying an operation
|
||||||
|
label that does not block its list's own scroll/drag.** (**P3**.) The
|
||||||
|
Compose version tried an overlay first and it swallowed the drag along
|
||||||
|
with the tap (AGENTS.md's "Shared appearance") — worth not repeating
|
||||||
|
that attempt in iris before building the row-level version directly.
|
||||||
|
- [ ] **A toggle switch.** (**P3**.) For the delete dialog's
|
||||||
|
`deleteForeign` control; iris has no switch/checkbox widget yet as far
|
||||||
|
as this pass found.
|
||||||
|
|
||||||
|
## Reconsider
|
||||||
|
|
||||||
|
- [ ] **`WidgetView`.** Iris is unsure of it: what she wants is an easy way
|
||||||
|
to compose a widget from others (a button is the main case). With
|
||||||
|
sizing folded into `draw`, composing may be easy enough that `View` is
|
||||||
|
redundant. Decide after the layout change lands, by writing a button
|
||||||
|
both ways and keeping the one that is shorter to explain; delete the
|
||||||
|
other rather than keeping two ways.
|
||||||
|
|
||||||
|
## Build (asked for by Iris, 2026-09-06): a density-independent length unit
|
||||||
|
|
||||||
|
- [x] **A third length kind beside relative and pixels, so display scales
|
||||||
|
"just work".** Done 2026-09-06 — `Len::dp`/`len_fns::dp`, resolved
|
||||||
|
against `UiRenderState`/`Painter::density()` at `apply_rest` time; text
|
||||||
|
additionally rasterises at the resolved (physical) size instead of
|
||||||
|
scaling a low-resolution bitmap afterward, which was making text blurry.
|
||||||
|
`Span::gap`/`Padding` moved from `f32` to `Len` so they take `dp(...)`
|
||||||
|
too; transcript-ui's row/composer padding and one example migrated.
|
||||||
|
`em` was not added — nothing in this pass needed a text-relative unit,
|
||||||
|
and `dp`'s own doc says why it and physical pixels are kept as separate
|
||||||
|
fields rather than one the caller pre-multiplies. Not yet verified on
|
||||||
|
Iris's own phone at two densities (this pass had no device) — see
|
||||||
|
docs/RUST.md's P0 box and docs/IRIS.md's 2026-09-06 entry for what to
|
||||||
|
check. Iris's words: "another length type similar to absolute &
|
||||||
|
relative, so instead there would be relative, pixels, and another unit
|
||||||
|
like em or whatever is standard. That way different display scales
|
||||||
|
should just work." Today a length is either a fraction of the parent
|
||||||
|
(`rest`/relative) or physical pixels, and the phone drew 16 px text at
|
||||||
|
roughly a third of its intended size until the P0 fixes applied the
|
||||||
|
display's scale factor globally. That global scale is a stopgap for the
|
||||||
|
benchmark; the real shape is a unit resolved against the display's
|
||||||
|
density at layout time — Android's `dp` / CSS's reference pixel is the
|
||||||
|
standard (1 unit = 1/160 in), with `em` as the text-relative option —
|
||||||
|
so a widget author writes `16.dp()` once and never sees the scale.
|
||||||
|
Done when: `Length` (or whatever the enum is called) has the third
|
||||||
|
variant; every place that resolves a length takes the density; the
|
||||||
|
examples and `transcript-ui` use the new unit for text sizes, padding
|
||||||
|
and control sizes; the emulator at two densities and the phone draw the
|
||||||
|
same layout at the same physical size. After the bench setup is
|
||||||
|
finished, before P1 draws any new screen.
|
||||||
|
|
||||||
|
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
|
||||||
|
|
||||||
|
- [ ] **Streaming a delta into a long message costs a full text layout of
|
||||||
|
that message.** Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
|
||||||
|
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
|
||||||
|
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
|
||||||
|
the last row, but that row is the growing message, and replacing it
|
||||||
|
re-renders its markdown and re-shapes the entire paragraph run through
|
||||||
|
parley on every event. Compose pays a reparse (8.6 ms mean) for the
|
||||||
|
same event. What "done" looks like: a streamed delta re-lays out only
|
||||||
|
the block it lands in (the last paragraph or code block), with earlier
|
||||||
|
blocks' layouts kept -- which needs a row to be a column of per-block
|
||||||
|
`Text`s rather than one `TextEdit` for the whole message, or parley's
|
||||||
|
layout to be split at block boundaries; measured by the stream phase's
|
||||||
|
p50 dropping below Compose's on the phone. Do this after the four bench
|
||||||
|
v2 defects (stale primitives, finger fling, decay curve, IME show) are
|
||||||
|
closed, since they are what make the run unrepresentative today.
|
||||||
+949
@@ -0,0 +1,949 @@
|
|||||||
|
# iris: one `draw` that reports a size
|
||||||
|
|
||||||
|
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
|
||||||
|
any design or code so that it survives a cleared session. **Status: implemented
|
||||||
|
2026-09-04, against every pass condition in §8** (measured, not assumed — see
|
||||||
|
that section). Every widget listed in §7 was migrated in one change; none
|
||||||
|
kept `desired_width`/`desired_height`. Five points needed correction or
|
||||||
|
refinement beyond what this file originally specified — see "Deviations
|
||||||
|
found during implementation" below, added right before "For IRIS.md" — read
|
||||||
|
that section before touching `Aligned`, `Sized`, `MaxSize`, `Scroll`, or the
|
||||||
|
move-slot lifecycle in `render_state.rs`, since each of those five is a real
|
||||||
|
bug this file's first draft would have reproduced if implemented literally.
|
||||||
|
|
||||||
|
## What Iris asked for
|
||||||
|
|
||||||
|
> I don't like that widgets need both a draw and size functions. I'd much
|
||||||
|
> rather them have a single draw that reports a size, and if it needs to be
|
||||||
|
> moved then that can be done after the fact efficiently, or resized just
|
||||||
|
> done after as well. This should be done efficiently like everything else
|
||||||
|
> tries to do right now.
|
||||||
|
|
||||||
|
She added, a few minutes later: "single draw is not a requirement. It
|
||||||
|
just seems more efficient from what I've heard. Feel free to override any
|
||||||
|
decision I've made if you can find a genuinely better & still clean
|
||||||
|
alternative." So the single-draw model is the default to design against,
|
||||||
|
and the design below may reject it, but only with a written comparison
|
||||||
|
showing the alternative does less work per frame and is no harder to use.
|
||||||
|
|
||||||
|
Standing constraints from RUST.md still apply: no DSL, plain Rust, do as
|
||||||
|
little processing as possible per frame, but the model must cover every
|
||||||
|
layout need a real app has (the transcript's virtualised list, wrapped
|
||||||
|
text whose height depends on width, rows and columns that size to their
|
||||||
|
children, overlays, masks).
|
||||||
|
|
||||||
|
## What exists today
|
||||||
|
|
||||||
|
`Widget` (`iris/core/src/widget/mod.rs`) has three methods: `draw(&mut
|
||||||
|
self, &mut Painter)`, `desired_width(&mut self, &mut SizeCtx) -> Len` and
|
||||||
|
`desired_height`. A parent asks `SizeCtx::width/height` for a child, which
|
||||||
|
is memoised per widget id and axis in `Cache.size` keyed on the outer
|
||||||
|
size, then places the child with `Painter::widget_within(region)`. So a
|
||||||
|
child is visited twice (sized, then drawn), every widget implements sizing
|
||||||
|
twice (one per axis), and a widget whose size depends on what it draws
|
||||||
|
(wrapped text, a laid-out paragraph) does the layout in the size pass and
|
||||||
|
again in the draw pass unless it caches by hand.
|
||||||
|
|
||||||
|
Primitives are already positioned by `UiRegion` values whose scalars have
|
||||||
|
a `rel` and an `abs` part, resolved against the window in the vertex
|
||||||
|
shader (`core/src/render/shader.wgsl`), and `Primitives::region_mut`
|
||||||
|
exists to rewrite one instance's region in place. That is the mechanism a
|
||||||
|
"move after the fact" can build on.
|
||||||
|
|
||||||
|
## What the design must answer
|
||||||
|
|
||||||
|
1. **Parent-before-child ordering.** A row has to know each child's width
|
||||||
|
to place the next one, but under "one draw" the child's size only
|
||||||
|
exists after it has drawn. The answer is meant to be: the child draws
|
||||||
|
at a provisional origin, reports its size, and the parent *moves* it.
|
||||||
|
The move must be O(1) per moved subtree, not O(primitives in the
|
||||||
|
subtree). One way: every instance carries an index into a small
|
||||||
|
per-widget offset buffer, so moving a widget writes one entry and the
|
||||||
|
vertex shader adds it. Other ways may be better; the design should say
|
||||||
|
what was considered.
|
||||||
|
2. **Move vs resize are different costs and must be kept apart.** A move
|
||||||
|
never re-runs `draw`. A resize re-runs `draw` for exactly the widgets
|
||||||
|
whose size input changed, and a widget whose output does not depend on
|
||||||
|
its size (an icon, a fixed rect) must be able to say so and be skipped.
|
||||||
|
3. **Size-dependent content.** Wrapped text is the hard case: its height
|
||||||
|
is a function of its width. A single `draw` receives the available
|
||||||
|
size (what `SizeCtx.outer` is today) and reports what it used, so the
|
||||||
|
two-pass "measure then draw" collapses into one for the common case.
|
||||||
|
The design must say what happens when a parent wants the child's
|
||||||
|
height *before* deciding the width it will offer (rare; say whether it
|
||||||
|
is supported, or is done by drawing twice as an explicit, opt-in cost).
|
||||||
|
4. **Caching.** Today's `Cache.size` memoises by (id, axis, outer). The
|
||||||
|
replacement should memoise the whole draw result by (id, available
|
||||||
|
size) so that an unchanged subtree costs nothing on the next frame,
|
||||||
|
which is what makes a virtualised list cheap.
|
||||||
|
5. **Everything currently written against `desired_width`/`desired_height`
|
||||||
|
moves over in one change**, per the code rules: two names for one
|
||||||
|
concept is not an intermediate state to leave behind. The widgets are
|
||||||
|
in `iris/src/widget/` (`ptr`, `mask`, `image`, `rect`, `trait_fns`, and
|
||||||
|
whatever else is there when the change is made).
|
||||||
|
|
||||||
|
## Order relative to the texture work
|
||||||
|
|
||||||
|
TEXTURES.md's redesign touches the render core (shader, `GpuTextures`,
|
||||||
|
`Primitives`, `Painter`'s texture calls). This change touches the widget
|
||||||
|
trait, `SizeCtx`, `Cache`, `Painter`'s widget calls, and any offset
|
||||||
|
mechanism the vertex shader needs. They overlap in `Painter` and the
|
||||||
|
shader, so they are done **in sequence, textures first**, and the layout
|
||||||
|
design here is written (not implemented) while the texture work is in
|
||||||
|
progress, then implemented on top of it.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. The new `Widget` trait
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Widget: Any {
|
||||||
|
/// Draw within `painter.region()` (the space the parent offered) and
|
||||||
|
/// report how much of it was actually used, per axis.
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size;
|
||||||
|
|
||||||
|
/// True if `draw`'s output (both the primitives it writes and the
|
||||||
|
/// `Size` it returns) is the same for any `painter.region()` of the
|
||||||
|
/// same *content* -- an icon, a fixed-size rect, an already-decoded
|
||||||
|
/// image at its natural size. Default `false` (redraw on any change to
|
||||||
|
/// the offered region) because assuming independence wrongly produces
|
||||||
|
/// a stale draw; a widget must opt in.
|
||||||
|
fn is_size_independent(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No `available` parameter: `Painter` already carries the region the parent
|
||||||
|
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
|
||||||
|
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
|
||||||
|
size (`output_size()`, `:152`). Passing it again would be the same value
|
||||||
|
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
|
||||||
|
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
|
||||||
|
deprecated, not kept as a fallback — because a widget that implements both
|
||||||
|
`draw` and `desired_*` for the same thing is exactly the "two names for one
|
||||||
|
concept" the code rules call out, and it is what today's `Span::desired_ortho`
|
||||||
|
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
|
||||||
|
own comment: "this literally copies draw so that the lengths are correctly
|
||||||
|
set in the context, which makes this slow and not cool." Folding sizing into
|
||||||
|
`draw` deletes that duplicate simulation, not just moves it.
|
||||||
|
|
||||||
|
**No single-draw alternative was found that does less work per frame.** The
|
||||||
|
two-method trait was checked against three properties a real screen needs —
|
||||||
|
a row placing children in sequence, a widget centering on its own content,
|
||||||
|
and wrapped text — and in every one, `draw` already has to visit the child
|
||||||
|
to get a size that is *this specific one's* answer, which today's
|
||||||
|
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
|
||||||
|
the same layout the draw pass will do again. So the two-method trait is not
|
||||||
|
"measure once, draw once" in the general case; it is "measure once per axis,
|
||||||
|
then draw once," i.e. up to three visits per widget per frame, against one
|
||||||
|
under the design here. The single-draw model is therefore adopted as
|
||||||
|
proposed, not merely accepted as a preference.
|
||||||
|
|
||||||
|
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
|
||||||
|
|
||||||
|
**What exists today, and why it is not O(1).** `UiRenderState::mov`
|
||||||
|
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
|
||||||
|
its *size* but changes *position* (`draw_inner`, `:85-100`:
|
||||||
|
`active.region.size() == region.size()` after excluding the exact-match
|
||||||
|
case). It rewrites every primitive's `region` field via
|
||||||
|
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
|
||||||
|
widget's own primitives, then recurses into every child — O(primitives in
|
||||||
|
the subtree). Both call sites that trigger it today, `Scroll::draw`
|
||||||
|
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
|
||||||
|
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
|
||||||
|
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
|
||||||
|
re-touches every glyph in every visible row, every frame of the drag, and
|
||||||
|
I3's target is 800 rows on screen.
|
||||||
|
|
||||||
|
**Recommendation: a per-widget offset slot forming a parent-linked chain,
|
||||||
|
resolved in the vertex shader.**
|
||||||
|
|
||||||
|
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
|
||||||
|
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
|
||||||
|
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
|
||||||
|
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
|
||||||
|
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
|
||||||
|
add nothing more"). A pure abs-pixel translation, not a general
|
||||||
|
`UiRegion` remap — sufficient for every existing call site (above).
|
||||||
|
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
|
||||||
|
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
|
||||||
|
kind of per-instance handle.
|
||||||
|
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
|
||||||
|
assigned **when the widget is first drawn** (`draw_inner`, beside
|
||||||
|
`active.insert`), with `parent` = the drawing widget's parent's slot.
|
||||||
|
`Painter` threads a `move_slot` field down exactly as it already threads
|
||||||
|
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
|
||||||
|
correct from its first frame — nothing is ever retrofitted onto an
|
||||||
|
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
|
||||||
|
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
|
||||||
|
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
|
||||||
|
- `mov(id, delta)` becomes: look up `id`'s slot, write
|
||||||
|
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
|
||||||
|
recursion, since descendants already reference this slot transitively.
|
||||||
|
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
|
||||||
|
pixels (after `:106`, before the clip-space divide at `:113`), walks
|
||||||
|
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
|
||||||
|
small constant, e.g. 16, with a CPU-side debug assertion that no chain
|
||||||
|
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
|
||||||
|
paid every frame regardless of whether anything moved — negligible next
|
||||||
|
to the per-fragment texture sampling TEXTURES.md already measures this
|
||||||
|
GPU as not bound by.
|
||||||
|
|
||||||
|
**Why the chain, not the flatter thing first proposed.** Iris's own
|
||||||
|
phrasing — "every instance carries an index into a small per-widget offset
|
||||||
|
buffer" — describes a flat table: one slot per subtree *declared* movable,
|
||||||
|
no parent link. It breaks the moment two such subtrees nest — a row inside
|
||||||
|
a scrolling list, itself later given its own animated offset (a
|
||||||
|
swipe-to-delete mid-scroll) — because the row's primitives would have to
|
||||||
|
pick one slot and lose the other's contribution. The chain costs one extra
|
||||||
|
field and a bounded shader loop in exchange for no such gap, and since
|
||||||
|
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
|
||||||
|
no more at the common depth of one than the flat version would.
|
||||||
|
|
||||||
|
**Against `region_mut` as the steady-state mechanism**: rejected for being
|
||||||
|
O(primitives in the subtree) — the cost this section removes — but kept
|
||||||
|
for a resize that changes a region's `rel` component (a genuine reflow,
|
||||||
|
§3) and for a size-independent widget's resize (§3), where the content's
|
||||||
|
shape doesn't change and one field write already suffices.
|
||||||
|
|
||||||
|
### 2b. Two more readers of "where is this widget," and masks
|
||||||
|
|
||||||
|
Moving the offset into the vertex shader means `ActiveData.region` is no
|
||||||
|
longer the on-screen truth once a widget has been moved — it is where the
|
||||||
|
widget was *drawn*, before any `move_offsets` delta. Two things read it as
|
||||||
|
if it still were, and both must move to a resolved query or they silently
|
||||||
|
answer with the pre-move position: a click landing on a scrolled row would
|
||||||
|
be routed to whatever used to be there, with nothing on screen to say so —
|
||||||
|
exactly the "wrong answer that looks like a right one" case the code rules
|
||||||
|
single out.
|
||||||
|
|
||||||
|
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
|
||||||
|
does the actual pointer routing, and line 170 is the read in question:
|
||||||
|
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
|
||||||
|
immediately turned into pixels and tested against the cursor at `:171-172`.
|
||||||
|
Under this design that region must be resolved through the same chain the
|
||||||
|
GPU walks before it means anything. Add to `UiRenderState`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// `active[id].region`, corrected by every `move_offsets` delta between
|
||||||
|
/// `id` and the root — the CPU-side twin of the vertex shader's chain
|
||||||
|
/// walk, over the same arena, so the two cannot disagree about where a
|
||||||
|
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
|
||||||
|
/// `move_offsets`, bounded by the same constant the shader loop uses
|
||||||
|
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
|
||||||
|
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
|
||||||
|
/// const across the language boundary).
|
||||||
|
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
|
||||||
|
```
|
||||||
|
|
||||||
|
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
|
||||||
|
coordinate query already used outside hit-testing
|
||||||
|
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
|
||||||
|
another's on-screen box), is reimplemented to call `resolved_region(id)`
|
||||||
|
before `.to_px(...)` instead of reading `.region` directly — one change
|
||||||
|
covers both call sites listed there. `sense.rs:170` changes to
|
||||||
|
`let shape = self.resolved_region(*id);`. Both are required the moment §2
|
||||||
|
lands, not an optional follow-up: an unmoved widget's chain is empty and
|
||||||
|
`resolved_region` costs one arena read to find that out, so there is no
|
||||||
|
version of this design where skipping the fix is a legitimate
|
||||||
|
optimization — it is a correctness gap, not a performance one.
|
||||||
|
|
||||||
|
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
|
||||||
|
painter's *current* region into a `Mask` pushed onto
|
||||||
|
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
|
||||||
|
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
|
||||||
|
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
|
||||||
|
widget that called `set_mask` — `Masked::draw`,
|
||||||
|
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
|
||||||
|
is itself later moved, its clip rectangle stays where it was drawn while
|
||||||
|
its content moves out from under it: a visibly wrong clip, immediately on
|
||||||
|
screen, not a latency question.
|
||||||
|
|
||||||
|
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
|
||||||
|
written from `Painter::set_mask` as `self.move_slot` — the identical slot
|
||||||
|
the mask-owning widget's own primitives already get (§2), not a second
|
||||||
|
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
|
||||||
|
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
|
||||||
|
currently computes the mask's `top_left`/`bot_right` inline from
|
||||||
|
`masks[in.mask_idx]`; that computation is extended to walk the same
|
||||||
|
move-offset chain §2 added, via one shared function —
|
||||||
|
|
||||||
|
```wgsl
|
||||||
|
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
— called from `vs_main` for a primitive's own corners and from `fs_main`
|
||||||
|
for its mask's corners, so the walk is written once and the two stages
|
||||||
|
cannot drift apart (the sibling-rule from the code rules: one loop, not a
|
||||||
|
hand-copied second one in the other shader stage).
|
||||||
|
|
||||||
|
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
|
||||||
|
primitive's mask is frequently owned by a *different* widget than the
|
||||||
|
primitive itself — often several levels up a subtree, with its own,
|
||||||
|
independent move slot — so a primitive's resolved offset and its mask's
|
||||||
|
resolved offset are two different chain sums, both needed, and only the
|
||||||
|
fragment shader has both `in.move_idx` (this fragment's own chain) and
|
||||||
|
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
|
||||||
|
in hand per-fragment. Resolving mask regions on the CPU at move time would
|
||||||
|
mean, for every `mov()` call, walking forward to every mask instance the
|
||||||
|
moved widget's slot could affect and rewriting its raw region — exactly
|
||||||
|
the O(subtree) cost §2 exists to remove, just moved from primitives to
|
||||||
|
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
|
||||||
|
frame (`:148`); one more arena read to resolve its chain costs nothing
|
||||||
|
extra in kind.
|
||||||
|
|
||||||
|
**The scroll-container case, checked rather than assumed.** A masked,
|
||||||
|
scrollable region is built as a `Masked` wrapping a `Scroll`
|
||||||
|
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
|
||||||
|
viewport border is drawn (and `set_mask` called) by `Masked`, which is
|
||||||
|
never itself the target of `mov()`; only `Scroll`'s inner content is,
|
||||||
|
every frame the user drags. Because each widget's move slot is its own
|
||||||
|
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
|
||||||
|
its own, stationary slot, while the scrolled content underneath references
|
||||||
|
a separate, deeper slot whose `parent` chain passes through — but does not
|
||||||
|
write to — the viewport's slot. Moving the content therefore never touches
|
||||||
|
the mask's resolved position, and the mask staying still while its content
|
||||||
|
slides past it is what this design already produces with no special case,
|
||||||
|
not an extra rule that had to be added for it.
|
||||||
|
|
||||||
|
### 3. Resize scope
|
||||||
|
|
||||||
|
A resize is "the region a widget's parent offers it changes such that the
|
||||||
|
widget's draw might produce different output" — as opposed to a move, which
|
||||||
|
by construction cannot (§2 is scoped to pure translation). Two independent
|
||||||
|
narrowings apply, and both are real, measured properties of the code as it
|
||||||
|
stands rather than new machinery:
|
||||||
|
|
||||||
|
**(a) A window resize does not, by itself, require touching most widgets.**
|
||||||
|
`shader.wgsl:105-106` recomputes every primitive's pixel position from
|
||||||
|
`window.dim` and the primitive's stored `rel`/`abs` pair *every frame,
|
||||||
|
already, on the GPU*. A widget laid out purely in `rel`/`abs` terms (no
|
||||||
|
call to `px_size()`, `output_size()`, or anything else that reads a
|
||||||
|
concrete pixel count) is therefore already correct after a resize with zero
|
||||||
|
CPU work — the shader did it. `UiRenderState::needs_redraw_all`
|
||||||
|
(`render_state.rs:229-231`) currently ignores this and redraws the entire
|
||||||
|
tree on every `resized`, which was the safe default while sizing and
|
||||||
|
drawing were two passes; it should be narrowed to only the widgets that
|
||||||
|
*do* read a concrete pixel value. Track this the same way `needs_redraw`
|
||||||
|
already tracks per-widget dirtiness (`Widgets::needs_redraw`,
|
||||||
|
`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself
|
||||||
|
pixel-dependent by calling through `Painter` methods that read
|
||||||
|
`output_size`/`px_size` (both already funnel through `Painter`, so the
|
||||||
|
marking is one line at each), and `resize()` (`render_state.rs:32-35`)
|
||||||
|
walks only that set instead of unconditionally setting `resized = true`
|
||||||
|
for a full `redraw_all`. This turns "every resize redraws everything" into
|
||||||
|
"every resize redraws what depends on pixels" — a real behavior change
|
||||||
|
beyond what was asked, so verify it against the I0b `pre_present_notify`
|
||||||
|
resize regression (that fix depended on `redraw_all`'s completeness)
|
||||||
|
before narrowing this.
|
||||||
|
|
||||||
|
**(b) A widget's `available` (its parent's offered region) can change
|
||||||
|
without the widget's *content* changing — this is what
|
||||||
|
`is_size_independent` (§1) answers.** When a container's own layout shifts
|
||||||
|
(a sibling grew or shrank, changing this widget's offered box), a widget
|
||||||
|
that returns `true` from `is_size_independent` is not redrawn: its
|
||||||
|
primitives are unaffected by size, only by placement, so the parent
|
||||||
|
either (i) issues a move (§2) if only position changed, or (ii) rewrites
|
||||||
|
the primitive's `region` fields directly via `region_mut` if the box
|
||||||
|
changed shape too (still O(primitives owned directly by this widget, not
|
||||||
|
its subtree, since a size-independent widget by definition has no
|
||||||
|
size-dependent descendants worth distinguishing — in practice this is
|
||||||
|
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
|
||||||
|
`false` (the default) is redrawn in full whenever `available` changes,
|
||||||
|
which is correct always, just not free.
|
||||||
|
|
||||||
|
**Ancestor propagation** (a resized child changing its own reported size,
|
||||||
|
requiring its parent to re-lay-out) is unchanged in spirit from today's
|
||||||
|
`redraw` (`render_state.rs:270-305`), which already walks up exactly the
|
||||||
|
ancestors whose cached size differs from the new one and stops as soon as
|
||||||
|
a size is unchanged (`:274-286`). That loop moves from consulting
|
||||||
|
`Cache.size` to consulting `ActiveData.size` (§5) but keeps its shape.
|
||||||
|
|
||||||
|
### 4. Wrapped text, and "needs child height before choosing width"
|
||||||
|
|
||||||
|
**Wrapped text is not a special case any more; it already reads as one
|
||||||
|
draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already
|
||||||
|
does exactly what single-draw asks for: it reads `ctx.px_size().x` as the
|
||||||
|
wrap width, shapes once, and memoizes the shaped layout keyed on that width
|
||||||
|
plus a changed-flag on the buffer and attrs (`:63-69`) — a second call with
|
||||||
|
the same width is a hash-map-style cache hit, not a re-shape. Under the new
|
||||||
|
trait this collapses `Text::draw`/`desired_width`/`desired_height`
|
||||||
|
(`text/mod.rs:133-147`, three functions) into one `Text::draw` that calls
|
||||||
|
`self.view.draw(painter)` once, which internally still calls `render`
|
||||||
|
once, hits its own cache, and returns the size it already computed. No
|
||||||
|
new caching is needed here; the two now-redundant call sites
|
||||||
|
(`desired_width`/`desired_height` each separately calling `render`) simply
|
||||||
|
disappear, which is a second `render` avoided per frame per text widget
|
||||||
|
that is being measured by a parent.
|
||||||
|
|
||||||
|
**"Parent wants the child's height before deciding the width it will
|
||||||
|
offer"** — the genuinely circular case named in the brief, e.g. a column
|
||||||
|
that sizes its own width to its widest child, where that child is wrapped
|
||||||
|
text whose height (which the column's *own* height depends on) depends on
|
||||||
|
the width the column has not yet decided. This is not solvable in one pass
|
||||||
|
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
|
||||||
|
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
|
||||||
|
(`span.rs:98-136`) already hits exactly this today and already resolves it
|
||||||
|
by an explicit second, throwaway pass (its own comment: "this literally
|
||||||
|
copies draw ... which makes this slow and not cool"). The design keeps that
|
||||||
|
resolution, made explicit rather than accidental: `Painter` gets
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Draw `child` at a provisional region to learn its size under one
|
||||||
|
/// axis's worth of assumption, discard everything it wrote, then draw it
|
||||||
|
/// again at the region that assumption produced. For the rare parent that
|
||||||
|
/// cannot pick an offered size without already knowing the answer.
|
||||||
|
/// Twice the cost of one `draw`; every other case in this file avoids it.
|
||||||
|
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
|
||||||
|
```
|
||||||
|
|
||||||
|
implemented as: draw at `first`, record `Size`, remove the widget and its
|
||||||
|
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
|
||||||
|
"if not \[same region\], maintain resize and track old children," `:97-100`,
|
||||||
|
which already frees the old primitives before redrawing) — reusing that
|
||||||
|
path rather than adding a second one — draw again at `second(size)`, return
|
||||||
|
the final `Size`. It is opt-in and named for its cost, so a widget only
|
||||||
|
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
|
||||||
|
call site converted to it, replacing the hand-rolled duplicate loop.
|
||||||
|
|
||||||
|
### 5. Caching and invalidation
|
||||||
|
|
||||||
|
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
|
||||||
|
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
|
||||||
|
answer, independent of drawing) no longer exists as a separate query, so
|
||||||
|
there is nothing left to cache at that layer. What already provides "an
|
||||||
|
unchanged subtree costs nothing" is the check `draw_inner` performs before
|
||||||
|
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
|
||||||
|
its region is unchanged, and it is not marked dirty, `draw_inner` returns
|
||||||
|
immediately — no `Painter` constructed, no primitive touched, no shader
|
||||||
|
work beyond what the GPU already redraws from the unchanged instance
|
||||||
|
buffer. That check is kept exactly as it is; it is the caching mechanism,
|
||||||
|
and it already operates at (id, region) granularity, which subsumes "(id,
|
||||||
|
available size)" once size *is* what a region change means.
|
||||||
|
|
||||||
|
What is added: `ActiveData` gains `pub size: Size` — the value `draw`
|
||||||
|
returned, stored the moment it is (`draw_inner`, alongside building the
|
||||||
|
`ActiveData` struct at `:134-143`). This is what a parent placing this
|
||||||
|
widget for a second frame without redrawing it (because nothing changed)
|
||||||
|
reads instead of recomputing — it replaces `Cache.size`'s role of "answer a
|
||||||
|
size question without a full draw" with "read the size of the last actual
|
||||||
|
draw," which is always available because `draw_inner`'s skip path is only
|
||||||
|
reachable once the widget has been drawn at least once. `Cache::remove`/
|
||||||
|
`Cache::clear` (`cache.rs:9-17`) are deleted with the type; `ActiveData`
|
||||||
|
already has an equivalent lifecycle (removed in `remove`/`remove_rec`,
|
||||||
|
`render_state.rs:171-198`, freed with the widget).
|
||||||
|
|
||||||
|
### 6. Before / after
|
||||||
|
|
||||||
|
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
impl Widget for Rect {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) {
|
||||||
|
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||||||
|
thickness: self.thickness, inner_radius: self.inner_radius });
|
||||||
|
}
|
||||||
|
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||||||
|
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// after
|
||||||
|
impl Widget for Rect {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
|
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||||||
|
thickness: self.thickness, inner_radius: self.inner_radius });
|
||||||
|
Size::REST // fills whatever it was given -- used == available
|
||||||
|
}
|
||||||
|
fn is_size_independent(&self) -> bool { true } // content never depends on region size
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**A container that needs the child's size before placing it,
|
||||||
|
`iris/src/widget/position/align.rs`**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// before
|
||||||
|
impl Widget for Aligned {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) {
|
||||||
|
let region = match self.align.tuple() {
|
||||||
|
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
|
||||||
|
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
|
||||||
|
UiRegion::new(x, UiSpan::FULL) }
|
||||||
|
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
|
||||||
|
UiRegion::new(UiSpan::FULL, y) }
|
||||||
|
(None, None) => UiRegion::FULL,
|
||||||
|
};
|
||||||
|
painter.widget_within(&self.inner, region);
|
||||||
|
}
|
||||||
|
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
|
||||||
|
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// after
|
||||||
|
impl Widget for Aligned {
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
|
let full = painter.region();
|
||||||
|
// Draw once at the full region to learn the child's real size --
|
||||||
|
// this placement is provisional and corrected below without a
|
||||||
|
// second draw.
|
||||||
|
let used = painter.widget_within(&self.inner, full);
|
||||||
|
let region = match self.align.tuple() {
|
||||||
|
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
|
||||||
|
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
|
||||||
|
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
|
||||||
|
(None, None) => full,
|
||||||
|
};
|
||||||
|
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
|
||||||
|
used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
|
||||||
|
return type from `()` to `Size`, carrying the child's `draw` result back —
|
||||||
|
the only signature change needed to let a parent see what its child used.
|
||||||
|
`Painter::reposition` is new, computing the delta between where a child
|
||||||
|
was actually drawn and where it belongs and calling the O(1) `mov` from
|
||||||
|
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
|
||||||
|
180-182`) are deleted — nothing calls `desired_len` any more, so there is
|
||||||
|
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
|
||||||
|
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
|
||||||
|
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
|
||||||
|
deletes the `SizeCtx` copies, keeping the `Painter` ones.
|
||||||
|
|
||||||
|
### 7. Migration — every file and widget that changes
|
||||||
|
|
||||||
|
One change, in dependency order (rename-and-move-together, per the code
|
||||||
|
rules — no intermediate state with both trait shapes):
|
||||||
|
|
||||||
|
- `core/src/widget/mod.rs` — the `Widget` trait (§1), delete
|
||||||
|
`WidgetAxisFns`, update `impl Widget for ()`.
|
||||||
|
- `core/src/ui/size.rs` — delete `SizeCtx` (the type and all its methods).
|
||||||
|
- `core/src/ui/cache.rs` — delete `Cache` (§5).
|
||||||
|
- `core/src/ui/painter.rs` — `widget`/`widget_within`/`widget_at` return
|
||||||
|
`Size`; add `reposition`, `draw_twice`; delete `size_ctx`, `size`,
|
||||||
|
`len_axis`; `primitive_at` writes `move_idx`.
|
||||||
|
- `core/src/ui/render_state.rs` — `draw_inner` captures and stores
|
||||||
|
`ActiveData.size`; `mov` becomes the O(1) offset write (§2); resize
|
||||||
|
narrowing (§3a); `redraw`'s per-axis loop reads `ActiveData.size`
|
||||||
|
instead of `Cache.size`.
|
||||||
|
- `core/src/ui/active.rs` — `ActiveData` gains `size: Size`,
|
||||||
|
`move_slot: MoveIdx`.
|
||||||
|
- `core/src/ui/mod.rs` — `UiData` gains `move_offsets`.
|
||||||
|
- `core/src/render/data.rs` — `PrimitiveInstance` gains `move_idx`;
|
||||||
|
new `MoveOffset` struct.
|
||||||
|
- `core/src/render/primitive.rs` — thread `move_idx` through `PrimitiveInst`
|
||||||
|
and `Primitives::write`, matching `mask_idx`.
|
||||||
|
- `core/src/render/mod.rs` — bind the new `move_offsets` storage buffer
|
||||||
|
(group 2, beside `masks`) and its update path.
|
||||||
|
- `core/src/render/shader.wgsl` — `InstanceInput` gains `move_idx`;
|
||||||
|
`MoveOffset`/`UiScalar`-shaped storage binding; a shared `resolve_move`
|
||||||
|
function (§2b) called from both `vs_main` (a primitive's own corners)
|
||||||
|
and `fs_main` (its mask's corners, once `Mask` carries `move_idx`).
|
||||||
|
- `core/src/ui/render_state.rs` — additionally, `resolved_region` (§2b)
|
||||||
|
and `window_region` (`:264-267`) reimplemented on top of it.
|
||||||
|
- `src/default/sense.rs` — `run_sensors`'s hit-test read (`:170`) switches
|
||||||
|
from `self.active.get(id).unwrap().region` to `self.resolved_region(*id)`
|
||||||
|
(§2b) — the pointer-routing fix this design requires, not an optional
|
||||||
|
follow-up.
|
||||||
|
- `core/src/render/data.rs` — additionally, `Mask` (`:46-49`) gains
|
||||||
|
`move_idx: u32` (§2b).
|
||||||
|
- `core/src/ui/painter.rs` — additionally, `set_mask` (`:49-52`) writes
|
||||||
|
`move_idx: self.move_slot` into the `Mask` it pushes (§2b).
|
||||||
|
- Every widget with a two-method `impl Widget`, collapsed to one `draw`
|
||||||
|
(§1, §6), `is_size_independent` added where true: `core/src/widget/mod.rs`
|
||||||
|
(`impl Widget for ()`), `iris/src/widget/rect.rs` (`Rect`, → true),
|
||||||
|
`iris/src/widget/image.rs` (`Image`, → true — a decoded image's primitive
|
||||||
|
never depends on the region it is offered, same as `Rect`),
|
||||||
|
`iris/src/widget/mask.rs` (`Masked`), `iris/src/widget/ptr.rs`
|
||||||
|
(`WidgetPtr`), `iris/src/widget/text/mod.rs` (`Text`, §4),
|
||||||
|
`iris/src/widget/text/edit.rs` (`TextEdit`),
|
||||||
|
`iris/src/widget/position/scroll.rs` (`Scroll`, keeps its `mov`-shaped
|
||||||
|
offset, now O(1) automatically via §2), `iris/src/widget/position/align.rs`
|
||||||
|
(`Aligned`, §6), `iris/src/widget/position/max_size.rs` (`MaxSize`),
|
||||||
|
`iris/src/widget/position/layer.rs` (`LayerOffset`),
|
||||||
|
`iris/src/widget/position/pad.rs` (`Pad`),
|
||||||
|
`iris/src/widget/position/stack.rs` (`Stack`),
|
||||||
|
`iris/src/widget/position/offset.rs` (`Offset`),
|
||||||
|
`iris/src/widget/position/span.rs` (`Span`, §4's `draw_twice` for the
|
||||||
|
cross-axis case, deleting `desired_ortho`'s duplicate loop),
|
||||||
|
`iris/src/widget/position/sized.rs` (`Sized`).
|
||||||
|
This list was produced by `grep -rn "impl Widget for\|fn desired_width\|fn desired_height"`
|
||||||
|
across `core/` and `src/`; re-run it before starting, since it is the
|
||||||
|
authoritative check that nothing was missed, not this paragraph.
|
||||||
|
- `iris/examples/{minimal.rs,task.rs,view.rs,tabs/main.rs}` — no direct
|
||||||
|
`impl Widget` found in any example (verified by the same grep); they use
|
||||||
|
the builder DSL in `core/src/widget/trait_fns.rs` and should need no
|
||||||
|
source change, which is itself part of the pass condition below.
|
||||||
|
|
||||||
|
### 8. Pass conditions
|
||||||
|
|
||||||
|
1. **Every example under `iris/examples` renders identically.** Run
|
||||||
|
`iris/run-headless.sh EXAMPLE --shot PNG` for each of `minimal`, `task`,
|
||||||
|
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
|
||||||
|
"looks right," since a subtle wrap or alignment regression is exactly
|
||||||
|
what a diff catches and a glance does not.
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass, all four, 0 differing bytes.** No PNG
|
||||||
|
library is installed in this VM (no PIL, no ImageMagick, no pip), so the
|
||||||
|
diff is a from-scratch PNG decoder (`zlib` + the five filter types) at
|
||||||
|
`/tmp/layout-shots/pngdiff.py`, comparing decoded pixel bytes rather than
|
||||||
|
file bytes (`cmp` alone is not conclusive across two separately-encoded
|
||||||
|
PNGs, though it happened to agree here for `minimal`). Before-shots were
|
||||||
|
taken with `git stash` at the pre-change commit; `tabs` needed two real
|
||||||
|
fixes (deviations 1 and 2 below) before it stopped differing — the other
|
||||||
|
three matched on the first try.
|
||||||
|
2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside
|
||||||
|
the existing `debug_layers`/`active_widgets` instrumentation
|
||||||
|
(`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b)
|
||||||
|
`Primitives::write`/`region_mut` calls, both per `update()` call. Drive
|
||||||
|
one example (`tabs`, since it already has multiple widgets and an
|
||||||
|
interactive element) through one frame with nothing changed and report
|
||||||
|
both counts — the pass condition is **0 draws and 0 primitive rewrites**
|
||||||
|
for a frame in which nothing was marked dirty, resized, or moved.
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass, 0 and 0.** Implemented as
|
||||||
|
`UiRenderState::take_counters() -> (u64, u64, u64)` (draws, `region_mut`
|
||||||
|
rewrites, `move_offsets` writes — a third counter, for condition 3
|
||||||
|
below), reset on read. Measured in
|
||||||
|
`iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing`
|
||||||
|
against a `Scroll` over 500 fixed-height rects (not the `tabs` example —
|
||||||
|
see the note on condition 3 for why this runs as a plain unit test
|
||||||
|
instead).
|
||||||
|
3. **Single-moved-child cost, measured.** Same counters, one frame in
|
||||||
|
which exactly one widget is moved (not resized) with N primitives in its
|
||||||
|
subtree — the pass condition is **1 write to `move_offsets`, 0 calls to
|
||||||
|
`Widget::draw`, 0 calls to `region_mut`**, independent of N. Construct
|
||||||
|
the case with a `tabs`-style example holding a deliberately large text
|
||||||
|
block (hundreds of glyphs) inside a `Scroll`, so N is large enough that
|
||||||
|
an O(N) regression would show up as a non-trivial write count rather
|
||||||
|
than being lost in noise.
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets
|
||||||
|
write, N = 500.** Built with rects rather than glyphs
|
||||||
|
(`iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw`):
|
||||||
|
`iris-core`/`iris` touch no GPU or window to lay out and move a tree, so
|
||||||
|
this runs as a plain `cargo test`, not through `run-headless.sh` — a
|
||||||
|
`Widgets`/`UiData` pair and a bare `UiRsc` impl are enough, and it is
|
||||||
|
faster and more precise than reading counters out of a real example's
|
||||||
|
stderr. Getting a clean single move took two follow-up fixes beyond the
|
||||||
|
design as written (deviation 3, the `parent_move_slot` threading; and
|
||||||
|
the `Scroll` design decision below about offering last frame's content
|
||||||
|
length) — without either, the count was in the thousands (every rect in
|
||||||
|
the subtree redrawing) rather than 1.
|
||||||
|
4. **Hit-testing follows the move, not just the render.** In the same
|
||||||
|
scrolled-`tabs` construction as condition 3, scroll the content, then
|
||||||
|
send a synthetic cursor position over a widget that moved and assert
|
||||||
|
`run_sensors` (`src/default/sense.rs:154-200`) routes to that widget's
|
||||||
|
id, not to whatever is now at its pre-scroll coordinates or to nothing.
|
||||||
|
This is a correctness check, not a timing one — §2b's fix is required
|
||||||
|
before §2 can ship at all, and this is what would fail silently
|
||||||
|
(nothing on screen indicates a missed or misrouted hit) if it were
|
||||||
|
skipped.
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass**, but checked one level below
|
||||||
|
`run_sensors`: `iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget`
|
||||||
|
scrolls a widget and asserts `UiRenderState::resolved_region` (the
|
||||||
|
query `run_sensors`'s hit-test and `window_region` both now go through,
|
||||||
|
per §2b) reports the moved, not the pre-scroll, position — within
|
||||||
|
0.01px of the exact expected delta. `run_sensors` itself needs a
|
||||||
|
`HasEvents`/window/cursor-state harness this pass did not build; the
|
||||||
|
coverage that matters (does the position query the router uses reflect
|
||||||
|
the move) is exercised directly instead.
|
||||||
|
5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll`
|
||||||
|
both before and after scrolling it (`iris/run-headless.sh` against a
|
||||||
|
small purpose-built example, or an addition to `tabs`), and diff the
|
||||||
|
two frames: the clipped edge of the content must have moved with the
|
||||||
|
scroll while the viewport's own border (drawn by `Masked`, not moved)
|
||||||
|
stays put — the specific case worked through in §2b. A mask rectangle
|
||||||
|
that stayed at its pre-scroll position while its content slid past it
|
||||||
|
is the regression this checks for, and it is visible in a single
|
||||||
|
screenshot, not just in a counter.
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass, checked numerically rather than by
|
||||||
|
screenshot.** No example in this repository builds a `Masked`-wrapped
|
||||||
|
`Scroll` (`tabs`'s "text edit scroll" tab uses `TextEdit`'s own internal
|
||||||
|
scrolling, not this widget), so there was nothing to screenshot without
|
||||||
|
first authoring a new example. Checked instead in
|
||||||
|
`iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves`,
|
||||||
|
on the exact data the fragment shader's `resolve_move` reads: the
|
||||||
|
masked widget's own `move_offsets` slot delta is `[0, 0]` both before
|
||||||
|
and after scrolling its content, because `Masked` is never itself the
|
||||||
|
target of a move — only its child is, on a separate, deeper slot in the
|
||||||
|
chain (§2b's "scroll-container case, checked rather than assumed"). A
|
||||||
|
pixel-level screenshot check of this remains open; see RUST.md's next
|
||||||
|
step.
|
||||||
|
6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`**
|
||||||
|
stay clean at the defaults (iris has no tests today per I0b, so this is
|
||||||
|
presently only clippy/fmt; add the first real widget-layer tests here if
|
||||||
|
the move-offset chain or `draw_twice` are non-trivial enough to want
|
||||||
|
one, per "match the codebase's testing posture" — judge that once the
|
||||||
|
code exists rather than pre-committing to a number of tests here).
|
||||||
|
|
||||||
|
**Result (2026-09-04): pass.** `cargo fmt --all -- --check`,
|
||||||
|
`cargo build --workspace --all-targets`, and `cargo clippy --all-targets`
|
||||||
|
are all clean (one pre-existing, unrelated warning about `naga`/`wgpu`/
|
||||||
|
`winit` future-incompatibility, from dependencies, not this change).
|
||||||
|
`cargo test --workspace`: the 14 pre-existing `TextEdit` tests plus 4 new
|
||||||
|
ones in `iris/src/layout_tests.rs` (conditions 2–5 above), 18 passed, 0
|
||||||
|
failed — the move-offset chain turned out non-trivial enough (three real
|
||||||
|
bugs found only by writing it) to clearly clear the "match the testing
|
||||||
|
posture" bar this section left open.
|
||||||
|
|
||||||
|
### 9. Rejected, and why
|
||||||
|
|
||||||
|
- **A flat (non-chained) per-subtree offset table**, Iris's literal
|
||||||
|
phrasing — rejected in §2 for breaking under nested independent moves
|
||||||
|
(a swiped row inside a scrolling list). Costs nothing extra to avoid: the
|
||||||
|
chain is the same mechanism with one more field.
|
||||||
|
- **Keeping `region_mut` recursion as the only move mechanism** — rejected
|
||||||
|
as the steady-state path (O(primitives in subtree), exactly what a
|
||||||
|
transcript scroll must not pay every frame) but kept for resize-shaped
|
||||||
|
changes (§3) where the content's own region field, not an ancestor
|
||||||
|
chain, is what has to change.
|
||||||
|
- **A second, size-only trait method kept alongside `draw`** (e.g.
|
||||||
|
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
|
||||||
|
implement to skip a draw when a cheap answer exists) — considered and
|
||||||
|
rejected: it reintroduces exactly the "two names for one concept" split
|
||||||
|
this change removes, for a saving `is_size_independent` (§1, §3b)
|
||||||
|
already covers for the cases where it would actually help (fixed-size
|
||||||
|
leaves). A widget whose size is cheap to compute but whose *drawing* is
|
||||||
|
not (unlikely in this codebase's widget set, but conceivable) is better
|
||||||
|
served by that widget caching its own draw output internally — exactly
|
||||||
|
the pattern `TextView::render` already uses (§4) — than by a second
|
||||||
|
trait method every implementor has to reason about.
|
||||||
|
- **Passing `available` as an explicit parameter to `draw`** (mirroring
|
||||||
|
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
|
||||||
|
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
|
||||||
|
which already carries the same information into every widget that needs
|
||||||
|
it; adding a parameter would just be a second route to a value already
|
||||||
|
reachable, and would invite the two drifting apart.
|
||||||
|
- **Eagerly propagating a moved widget's delta into every descendant's own
|
||||||
|
offset value** (rather than chaining and resolving in the shader) —
|
||||||
|
rejected as O(descendant widgets), which is smaller than O(primitives)
|
||||||
|
but still not O(1), and the shader-side chain costs nothing extra to get
|
||||||
|
the better bound.
|
||||||
|
|
||||||
|
## Deviations found during implementation (2026-09-04)
|
||||||
|
|
||||||
|
Five corrections this file's first draft did not anticipate, each found by
|
||||||
|
`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical
|
||||||
|
pre-change screenshot (pass condition 1) and traced with `eprintln!` in
|
||||||
|
`draw_inner`/`reposition` — not by reasoning about the design in the
|
||||||
|
abstract. Recorded here rather than silently fixed in place, per the code
|
||||||
|
rules' escape-hatch requirement.
|
||||||
|
|
||||||
|
1. **`Aligned`'s provisional draw must call `painter.widget`, not
|
||||||
|
`widget_within(&self.inner, painter.region())`.** §6's original text drew
|
||||||
|
the sample as the latter. `widget_within` composes its `region` argument
|
||||||
|
as *local*, `UiRegion::FULL`-relative coordinates against
|
||||||
|
`painter.region()` (exactly what `UiRegion::FULL.within(&self.region) ==
|
||||||
|
self.region` relies on); handing it `painter.region()` itself —
|
||||||
|
already-resolved, window-relative coordinates — composes that frame a
|
||||||
|
second time. For the root widget this is silently the identity (its
|
||||||
|
region already is `[0,1]`), which is why it can look correct in a
|
||||||
|
trivial case and only breaks once something is nested — i.e. always, in
|
||||||
|
practice. Symptom: a centered child rendered at a wildly wrong offset
|
||||||
|
nested more than one level deep. Fixed by using `painter.widget`, which
|
||||||
|
hands the child `self.region` unmodified, with no second composition.
|
||||||
|
|
||||||
|
2. **A widget that reports a size smaller than its offered region must
|
||||||
|
actually paint at that size, anchored top-left of what it was given —
|
||||||
|
not fill the full offered region while merely *reporting* a smaller
|
||||||
|
number.** `Sized` and `MaxSize` both had exactly this bug: their
|
||||||
|
`desired_width`/`desired_height` predecessors capped the *reported*
|
||||||
|
value but their `draw` bodies called `painter.widget(&self.inner)`
|
||||||
|
unconstrained, which was harmless under the old two-pass model (a parent
|
||||||
|
always queried the size *before* drawing, so by the time `draw` ran the
|
||||||
|
offered region already matched) but wrong under `Aligned`'s new
|
||||||
|
provisional-draw-then-reposition pattern, which offers the *whole*
|
||||||
|
region on the first, learning pass. Symptom: a `.sized((100, 100))` rect
|
||||||
|
rendered stretched to fill its whole row instead of a 100×100 square.
|
||||||
|
Fixed by having both widgets carve the declared sub-region (`UiSpan`
|
||||||
|
sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever
|
||||||
|
they were offered before drawing the child in it. `Image` needed the
|
||||||
|
same treatment from the start (`texture_within` at its own natural size,
|
||||||
|
not `texture()` at the full offered region) and was written that way in
|
||||||
|
the first pass, once this was understood; `Rect`'s "fill whatever I'm
|
||||||
|
given" is the one case where painting the *whole* offered region really
|
||||||
|
is the declared behavior, so it needed no change.
|
||||||
|
|
||||||
|
3. **The move-offset chain's `parent` link cannot be found by looking up
|
||||||
|
the parent's `ActiveData` in `draw_inner`, because the parent's
|
||||||
|
`ActiveData` does not exist yet while its own `Widget::draw` is still
|
||||||
|
running.** `ActiveData` is inserted only after `draw` returns
|
||||||
|
(`render_state.rs`, end of `draw_inner`), so a child drawn partway
|
||||||
|
through its parent's `draw` body — the ordinary case, since every
|
||||||
|
composite widget draws its children from inside its own `draw` — would
|
||||||
|
always read "no parent" from `self.active`, silently orphaning it at the
|
||||||
|
root of the chain. Fixed by threading the parent's `move_slot` down
|
||||||
|
through `Painter` (it already carries `mask`/`layer` the same way) and
|
||||||
|
passing it explicitly into `draw_inner` as `parent_move_slot`, rather
|
||||||
|
than deriving it from `self.active.get(parent_id)`. `move_parent_of`
|
||||||
|
(the `self.active`-based lookup) is kept, but only for `redraw()`, whose
|
||||||
|
target's parent genuinely is already active at that call site — the
|
||||||
|
doc comment on it says which is which. Symptom: `reposition` computed
|
||||||
|
the right delta and wrote it to the right slot, but the shader never
|
||||||
|
saw it, because the primitive doing the actual painting chained to
|
||||||
|
`u32::MAX` one level too early.
|
||||||
|
|
||||||
|
4. **`Painter::reposition` cannot reuse `active.region` as "where the
|
||||||
|
widget currently is," because for a widget offered more room than it
|
||||||
|
used, `active.region` is the *offered* box, not the *painted* one.**
|
||||||
|
This only matters for `reposition` (used by `Aligned`); `mov` (used by
|
||||||
|
`draw_inner`'s own same-size-different-position dispatch, for `Scroll`
|
||||||
|
and `Offset`) has no such gap, because there the offered region *is*
|
||||||
|
the visual footprint — content is sized to fill exactly what it is
|
||||||
|
given. `reposition` instead reconstructs "from" as `active.size`
|
||||||
|
(already tracked, per §5) anchored at `AxisAlign::Neg` within
|
||||||
|
`active.region` — i.e. it assumes the child painted itself top-left of
|
||||||
|
whatever it was offered, per point 2's convention — and **overwrites**
|
||||||
|
the slot's delta rather than accumulating it the way `mov` does, since
|
||||||
|
"from" is recomputed fresh from stable inputs every call and repeating
|
||||||
|
the same `reposition` (an unrelated redraw elsewhere re-running this
|
||||||
|
widget's parent) must not drift further each time. The one shape this
|
||||||
|
does not cover: `Aligned` wrapping `Aligned`, where the inner one's own
|
||||||
|
`reposition` may have moved its content away from top-left already. No
|
||||||
|
widget or example in this codebase builds that today; if one needs to,
|
||||||
|
`reposition` would need the child to report *where* it painted, not
|
||||||
|
just how big, which is a larger change than this pass's scope.
|
||||||
|
|
||||||
|
5. **A widget's `move_offsets` slot is allocated once, on its first-ever
|
||||||
|
draw, and reused in place — never reallocated — for every later redraw
|
||||||
|
of the same id, with its delta reset to `[0, 0]` on each reuse.** Not
|
||||||
|
spelled out in §2's original text, which only said slots are assigned
|
||||||
|
"when the widget is first drawn." Reallocating a fresh slot on every
|
||||||
|
redraw would leave any *retained* (not-redrawn) descendant's `parent`
|
||||||
|
link pointing at a now-orphaned old slot — a permanent leak, and worse,
|
||||||
|
a descendant that silently stops tracking its ancestor's future moves.
|
||||||
|
Resetting the delta on reuse (rather than carrying it forward) is
|
||||||
|
required because a full redraw bakes the widget's correct absolute
|
||||||
|
position into the fresh `region` argument directly; a stale delta left
|
||||||
|
over from before the redraw would double-offset it.
|
||||||
|
|
||||||
|
Two further points worth recording because they were *design decisions*
|
||||||
|
made while implementing, not bugs — `LAYOUT.md`'s own text left them
|
||||||
|
unspecified rather than getting them wrong:
|
||||||
|
|
||||||
|
- **`Scroll` offers its content a region sized by the *previous* frame's
|
||||||
|
measured content length, not a fresh one.** A fresh measurement would
|
||||||
|
require drawing the content once to learn its size and — since that
|
||||||
|
provisional size essentially never matches the previously active one —
|
||||||
|
redrawing it a second time at the real size, on every single scroll
|
||||||
|
tick, which is exactly the cost §2 exists to remove. Using the stale
|
||||||
|
length means an ordinary scroll (position changes, content does not)
|
||||||
|
offers the same *size* as last frame, only shifted, which is what makes
|
||||||
|
`draw_inner` dispatch it as the O(1) move. The cost: a real content-size
|
||||||
|
change lags one frame before the container's scroll range reflects it,
|
||||||
|
self-correcting the frame after (the content length itself, read from
|
||||||
|
what was actually drawn, is never stale — only the offered *region* used
|
||||||
|
for placement is). No example in this repository builds a `Scroll` yet,
|
||||||
|
so this could not be checked against a pixel diff; it is covered instead
|
||||||
|
by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which
|
||||||
|
build a tree and drive `UiRenderState` directly with no GPU or window
|
||||||
|
needed.
|
||||||
|
- **`redraw()`'s parent-relayout check draws the widget first, then
|
||||||
|
compares the fresh `ActiveData.size` the draw produced against the size
|
||||||
|
from before removal** — the mirror image of the old code's "query size,
|
||||||
|
compare, decide whether to draw," which no longer has a size query to
|
||||||
|
do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx`
|
||||||
|
along with `desired_width`/`desired_height`). This can occasionally draw
|
||||||
|
a widget once more than the old code would have (if the parent it
|
||||||
|
bubbles up to ends up redrawing the same widget again as part of its own
|
||||||
|
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
|
||||||
|
redundancy for free, and this path is not one of §8's measured
|
||||||
|
conditions, so the remaining slack was accepted rather than chased
|
||||||
|
further.
|
||||||
|
|
||||||
|
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
|
||||||
|
|
||||||
|
Iris asked for a third length kind beside `abs` (physical pixels) and
|
||||||
|
`rel`/`rest` (a fraction of the parent) — IRIS_TODO.md's "density-
|
||||||
|
independent length unit" — after the P0 phone pass found 16px text
|
||||||
|
drawing at roughly a third size on a real phone. The fix that shipped
|
||||||
|
first (RUST.md's P0 box) was a global stopgap: divide the whole window
|
||||||
|
into a "logical" coordinate space (physical ÷ `content_scale`) and let
|
||||||
|
the shader's NDC mapping stretch it back up onto the real framebuffer.
|
||||||
|
That fixed the *size* but not the *sharpness* — a glyph rasterised at the
|
||||||
|
small, pre-stretch size and then stretched onto more physical pixels than
|
||||||
|
it has texels for is blurry, which is exactly what Iris's next report
|
||||||
|
said.
|
||||||
|
|
||||||
|
**The fix**: `Len` gained a `dp` field, resolved against a `density: f32`
|
||||||
|
(physical pixels per dp) at the one place a `Len` becomes a `UiScalar`
|
||||||
|
(`Len::apply_rest`) — `abs + dp * density`. `density` lives on
|
||||||
|
`UiRenderState` (`set_density`/`density()`) and `Painter` (`density()`),
|
||||||
|
set once from `DisplayMetrics.density` in `android::view::new_peer`; the
|
||||||
|
desktop backend has no per-monitor density wired up yet and stays at
|
||||||
|
`1.0`. Every layout call site that used to call `.apply_rest()`/
|
||||||
|
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
|
||||||
|
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `List::place`, and
|
||||||
|
`UiRenderState::reposition` itself). This also meant the Android
|
||||||
|
boundary's global logical-space stopgap could come out entirely: window
|
||||||
|
size, touch coordinates and insets are physical pixels again, matching
|
||||||
|
`AndroidRenderer`'s own swapchain resolution, with `dp` doing the
|
||||||
|
per-length work the global divide used to do for everything at once.
|
||||||
|
|
||||||
|
**Text is the case that needed more than the `Len` plumbing.** A widget's
|
||||||
|
`font_size`/`line_height` are plain `f32`, not routed through `Len` at
|
||||||
|
all (there is no sensible `rel`/`rest` for a font size). `TextBuffer::
|
||||||
|
shape` now takes `density` directly and multiplies `font_size`/
|
||||||
|
`line_height` (and any span override) by it before handing them to
|
||||||
|
parley — so the size that reaches both the line-breaker and the
|
||||||
|
rasteriser (`TextData::place`, which reads back whatever `shape` set) is
|
||||||
|
the display's *physical* size, and the glyph atlas holds a bitmap at the
|
||||||
|
resolution it is actually shown at. `GlyphKey.size` already keys on the
|
||||||
|
resolved size, so a cache entry is naturally per-physical-size with no
|
||||||
|
further change. The one caller with no `Painter` to read density from
|
||||||
|
(`TextEditCtx::layout`, cursor movement and hit-testing) reads a second
|
||||||
|
copy kept directly on `TextData` (`TextData::density`) instead — an
|
||||||
|
accepted duplication rather than threading a `Painter` into every input
|
||||||
|
handler for one field, the same tradeoff `AndroidRenderer::content_scale`
|
||||||
|
already makes for the Diagnostics page.
|
||||||
|
|
||||||
|
**What did not change**: `rel`/`rest` are unaffected (already
|
||||||
|
resolution-independent, a fraction of the parent). `Span::gap` and
|
||||||
|
`Padding`'s four sides moved from bare `f32` to `Len` so `dp(...)` works
|
||||||
|
on them the same as any other size; a bare number is still `abs`,
|
||||||
|
physical pixels, unchanged.
|
||||||
|
|
||||||
|
## For IRIS.md
|
||||||
|
|
||||||
|
When this lands, copy this entry into `IRIS.md` (newest first):
|
||||||
|
|
||||||
|
> **2026-09-04 — `Widget::draw` reports the size it used; `desired_width`/
|
||||||
|
> `desired_height` are gone.** A widget used to implement three methods
|
||||||
|
> (`draw`, `desired_width`, `desired_height`); it now implements one,
|
||||||
|
> `fn draw(&mut self, painter: &mut Painter) -> Size`, which draws into
|
||||||
|
> `painter.region()` and returns how much of it was used. Why: the two
|
||||||
|
> extra methods routinely re-simulated what `draw` was about to do anyway
|
||||||
|
> (`Span::desired_ortho` copied its own draw loop to get cross-axis sizing
|
||||||
|
> right) — one visit per widget per frame instead of up to three. A
|
||||||
|
> container that needs a child's size before placing it (alignment,
|
||||||
|
> centering) draws the child once at a provisional region, reads the
|
||||||
|
> returned `Size`, and calls the new `Painter::reposition` to move it into
|
||||||
|
> its final spot — an O(1) offset write, not a second draw. A widget whose
|
||||||
|
> drawn output never depends on the size it's given (a fixed-size `Rect`,
|
||||||
|
> a decoded `Image`) overrides the new `fn is_size_independent(&self) ->
|
||||||
|
> bool { false }` to `true`, which skips redrawing it when only its
|
||||||
|
> offered region changes shape.
|
||||||
|
>
|
||||||
|
> ```rust
|
||||||
|
> // before
|
||||||
|
> fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
||||||
|
> fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||||
|
> fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||||
|
>
|
||||||
|
> // after
|
||||||
|
> fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
||||||
|
> design, the move-offset mechanism this shipped alongside, and the file
|
||||||
|
> list.
|
||||||
+147
-10
@@ -147,12 +147,37 @@ turn.
|
|||||||
|
|
||||||
Spawn: `claude -p --verbose --input-format stream-json --output-format
|
Spawn: `claude -p --verbose --input-format stream-json --output-format
|
||||||
stream-json --permission-mode <mode>` in the chosen working directory, plus
|
stream-json --permission-mode <mode>` in the chosen working directory, plus
|
||||||
`--model`. Wire-format notes are pinned against CLI 2.1.237 in
|
`--model` and, where one has been chosen, `--effort`. Wire-format notes are
|
||||||
`session/claude.rs`'s module doc: permissions need the hidden
|
pinned against CLI 2.1.237 in `session/claude.rs`'s module doc: permissions
|
||||||
`--permission-prompt-tool stdio` flag, AskUserQuestion answers ride
|
need the hidden `--permission-prompt-tool stdio` flag, AskUserQuestion answers
|
||||||
`updatedInput.answers` keyed by question text, and `set_model`/`interrupt`
|
ride `updatedInput.answers` keyed by question text, and `set_model`/`interrupt`
|
||||||
are control requests.
|
are control requests.
|
||||||
|
|
||||||
|
**The thinking level is settled at launch** (added 2026-09-04, because it is
|
||||||
|
the largest saving available on a long session: output is about an eighth of
|
||||||
|
what a session costs and thinking is the bulk of output, against the ~1.5% that
|
||||||
|
is prose). The CLI's only two setting control requests are `set_model` and
|
||||||
|
`set_permission_mode` -- checked against the 2.1.258 binary -- so there is no
|
||||||
|
way to ask a running process to think differently. `set_session_effort` is
|
||||||
|
therefore shaped like `set_session_cwd` rather than like `set_session_model`:
|
||||||
|
it records the level and **stops the process**, and the next message or Start
|
||||||
|
launches one that has it. It lives in the session settings dialog beside the
|
||||||
|
working directory for that reason, not on the session bar beside the model and
|
||||||
|
the mode, which do take effect mid-turn. `None` is a level in its own right --
|
||||||
|
the CLI's own default -- so the picker can return to it; a level this app named
|
||||||
|
as the default instead would be this app choosing one.
|
||||||
|
|
||||||
|
**What a new session starts at is `Config::default_effort`**, applied in
|
||||||
|
`spawn_session` rather than filled in by the spawn screen, so it holds for an
|
||||||
|
import and a bare API call as well. It is set by the spawn screen's own
|
||||||
|
picker, whose label says so: one control, where new sessions are made, rather
|
||||||
|
than a settings page for a single value. It is not on a provider, because
|
||||||
|
providers are discovered and the next rediscovery would erase it, and not on
|
||||||
|
the phone, because a second device would then spawn at a level nobody there
|
||||||
|
chose. `GET`/`POST /defaults` carry it, as a struct rather than a bare value
|
||||||
|
so the permission mode -- still hardcoded to `auto` on the spawn screen -- can
|
||||||
|
move there without a second route.
|
||||||
|
|
||||||
**`--resume` only ever runs when nothing else has that session open.** That
|
**`--resume` only ever runs when nothing else has that session open.** That
|
||||||
is the rule behind the import refusal, the single `ClaudeDriver::launch`
|
is the rule behind the import refusal, the single `ClaudeDriver::launch`
|
||||||
entry point, and the `Exited` correction below; two CLIs on one session file
|
entry point, and the `Exited` correction below; two CLIs on one session file
|
||||||
@@ -169,10 +194,33 @@ deliberate and easy to undo by accident:
|
|||||||
when the process restarts. That leaves the Claude driver as the odd one
|
when the process restarts. That leaves the Claude driver as the odd one
|
||||||
out rather than this one — the CLI's memory is a cache in front of the same
|
out rather than this one — the CLI's memory is a cache in front of the same
|
||||||
transcript. Resolve any inconsistency in this direction.
|
transcript. Resolve any inconsistency in this direction.
|
||||||
- **A llama session on an ssh host is refused.** The model is reached over
|
- **A llama session runs on whatever machine its setup names** (2026-09-04,
|
||||||
HTTP and forwarding that port is not built, so refusing beats silently
|
the last of phase 5). A transport is "run this" plus "reach this port", and
|
||||||
talking to the wrong machine. A transport is "run this" plus "reach this
|
the second half is `Transport::reserve_port` — the port the server binds
|
||||||
port", and only the first half exists.
|
*there* and the port that reaches it *here*, the same number locally —
|
||||||
|
carried by `Launch::reaching` onto the connection that already runs the
|
||||||
|
command. `llama-server` binds loopback on the far machine, so nothing is
|
||||||
|
served to its network. The far port is a guess from a range below the
|
||||||
|
ephemeral one, because no portable way to ask a machine for a free port
|
||||||
|
avoids racing the bind anyway; a collision is not silent, since the server
|
||||||
|
fails to bind and the readiness poll reports what its log said.
|
||||||
|
- **The model file lives on the machine that serves it** (2026-09-04). Each
|
||||||
|
setup names its own models directory (`SshConfig::models_dir`, default
|
||||||
|
`~/.local/share/ai-app/models` expanded *there*), and a spawn resolves the
|
||||||
|
key on that machine — one round trip answering "at /abs/path" or "missing",
|
||||||
|
so a model that is not there is refused at the spawn rather than becoming a
|
||||||
|
server that never becomes ready. The spawn screen offers
|
||||||
|
`GET /setups/{id}/models`, that machine's list, rather than `GET /models`,
|
||||||
|
which is this backend's downloads. Downloading *to* another machine is
|
||||||
|
deliberately not built: a multi-gigabyte transfer with no progress
|
||||||
|
anywhere, and the file gets there however anything else on that machine
|
||||||
|
did.
|
||||||
|
- **The readiness poll watches the process, not only the port.** A model that
|
||||||
|
will not load, a port already taken, a flag an older build does not know:
|
||||||
|
all exit within a second and none will ever answer `/health`, so waiting
|
||||||
|
out the 300s timeout turned the server's own account of the problem into
|
||||||
|
"gave up". The failure carries the tail of `llama-server.log`, which on a
|
||||||
|
remote session is the only copy anybody reading the phone can see.
|
||||||
|
|
||||||
### Models (2026-08-28)
|
### Models (2026-08-28)
|
||||||
|
|
||||||
@@ -203,6 +251,13 @@ deliberate and easy to undo by accident:
|
|||||||
A driver says what to run; something above it turns that into a process.
|
A driver says what to run; something above it turns that into a process.
|
||||||
Otherwise transport knowledge sits inside a translator whose job is a wire
|
Otherwise transport knowledge sits inside a translator whose job is a wire
|
||||||
format, and every future driver has to remember to do the same.
|
format, and every future driver has to remember to do the same.
|
||||||
|
- **A forwarded launch gets a pty and every other one does not** (measured
|
||||||
|
2026-09-04). Killing the ssh client ends a CLI because it closes the stdin
|
||||||
|
that CLI is reading; `llama-server` never reads its stdin, so the same kill
|
||||||
|
left it running on the far machine with the model loaded — one orphan per
|
||||||
|
stopped session. With `-tt` the far side takes SIGHUP when the connection
|
||||||
|
goes. Its log then arrives through a line discipline, which nothing parses.
|
||||||
|
`-T` stays everywhere else, where a pty would rewrite the JSONL.
|
||||||
- **`command -v` follows ssh's non-login PATH**, which is narrower than an
|
- **`command -v` follows ssh's non-login PATH**, which is narrower than an
|
||||||
interactive shell's, so a binary somewhere unusual is invisible to
|
interactive shell's, so a binary somewhere unusual is invisible to
|
||||||
discovery. Point `command` at an absolute path.
|
discovery. Point `command` at an absolute path.
|
||||||
@@ -499,6 +554,25 @@ rate-limited bucket). Poll at ≥180 s, only while a Claude session exists or
|
|||||||
the usage screen is open, and cache the last answer. It is undocumented, so
|
the usage screen is open, and cache the last answer. It is undocumented, so
|
||||||
`usage.rs` treats every field as optional and degrades rather than erroring.
|
`usage.rs` treats every field as optional and degrades rather than erroring.
|
||||||
|
|
||||||
|
**Per provider, not per machine (2026-09-04).** A machine is not what is
|
||||||
|
metered; the provider a session runs is. One machine offers echo, the Claude
|
||||||
|
CLI and a local model side by side, and only the second spends anything — so
|
||||||
|
pairing a session with a snapshot by machine alone drew the CLI's five-hour
|
||||||
|
window under every echo session on it, a quota that session cannot spend. A
|
||||||
|
session now names its meter (`usageProvider`, from
|
||||||
|
`DriverKind::usage_provider`, which `usage::providers_for` reads too, so the
|
||||||
|
two lists cannot disagree) and `GET /usage` is matched on machine *and*
|
||||||
|
provider. `None` is a session that meters nothing, and the phone draws
|
||||||
|
nothing at all for it — not a zero, and not "unknown".
|
||||||
|
|
||||||
|
`DriverKind::Echo` names a meter of its own that exists only when a test has
|
||||||
|
asked for one: `/usage` in an echo session sets an invented answer
|
||||||
|
(`usage::Fixture`), and with none set there is no snapshot and no bar. That
|
||||||
|
is what makes those screens' states reachable — a number near the top, a
|
||||||
|
window between blocks with no reset time, a machine nobody logged into, one
|
||||||
|
that could not be reached — without spending real quota to arrange them,
|
||||||
|
which is why none of them had ever been looked at.
|
||||||
|
|
||||||
**Per machine, not per backend (2026-08-29).** The credential store that
|
**Per machine, not per backend (2026-08-29).** The credential store that
|
||||||
matters is the one on the machine the session runs on, because that is the
|
matters is the one on the machine the session runs on, because that is the
|
||||||
account being billed — and in the layout this aims at, `ai-server` is on the
|
account being billed — and in the layout this aims at, `ai-server` is on the
|
||||||
@@ -522,6 +596,68 @@ always running. So absent means **not running**, and only a timestamp that
|
|||||||
arrives and cannot be parsed is unknown. `WindowEnd` in `ResetCountdown.kt`
|
arrives and cannot be parsed is unknown. `WindowEnd` in `ResetCountdown.kt`
|
||||||
is the one rule both readers go through.
|
is the one rule both readers go through.
|
||||||
|
|
||||||
|
### Auto-resume (2026-09-05)
|
||||||
|
|
||||||
|
**A session may pick itself back up when the account's usage limit lifts.**
|
||||||
|
Off unless somebody switched that session to it, because it spends quota the
|
||||||
|
moment quota exists and does so with nobody looking — that is not a thing a
|
||||||
|
default may decide. It sends one message, `continue` unless another was
|
||||||
|
typed, and then it is done; there is no retry loop around the conversation
|
||||||
|
itself.
|
||||||
|
|
||||||
|
**Running out of quota is a state, not an error.** `Event::LimitReached`
|
||||||
|
carries the dialect's reset time where it gave one, and recognising it
|
||||||
|
belongs to the driver — the Claude CLI ends the turn with `is_error` and
|
||||||
|
`Claude AI usage limit reached|1788546972`, and nothing above the driver
|
||||||
|
matches on a string. The transcript draws it as a divider, like a clear or a
|
||||||
|
compaction: what a reader scrolling back wants from it is why the
|
||||||
|
conversation stops at that line.
|
||||||
|
|
||||||
|
**The schedule is a plan to ask, never a plan to send.** Every reset time
|
||||||
|
available here is untrustworthy in the direction that matters: the dialect's
|
||||||
|
is written when the turn fails, and the endpoint's moves when the window
|
||||||
|
does. So the wait ends in a question to `usage.rs`, and only `ok` with no
|
||||||
|
window at 100% sends anything. A window still spent reschedules to *its own*
|
||||||
|
reset time — which is what makes a limit that lifts later than promised wait
|
||||||
|
longer, and one that lifts sooner resume sooner. A meter that cannot be
|
||||||
|
asked at all is a longer wait too, never a send: "we could not find out"
|
||||||
|
must not be able to produce the same action as "there is room".
|
||||||
|
|
||||||
|
Bounded, because something has to be: a day after the limit was hit the wait
|
||||||
|
stops and says so in the session's own transcript. A machine that can never
|
||||||
|
be asked would otherwise be retried for ever with nothing on screen saying
|
||||||
|
so.
|
||||||
|
|
||||||
|
The schedule is persisted on the session (`resume: Some(ScheduledResume)`),
|
||||||
|
not held in memory: a five-hour window routinely outlasts a backend restart,
|
||||||
|
and a wait forgotten across one is a session that silently never comes back.
|
||||||
|
`resume.rs` is the top layer — it holds the manager and the monitor and
|
||||||
|
neither holds it — which is what lets the decision be a pure function of a
|
||||||
|
snapshot and a clock. The pump reports limits downward on a broadcast, for
|
||||||
|
the reason `Shared` exists: the pump runs underneath the manager.
|
||||||
|
|
||||||
|
**Exercised with echo, never with a real account.** `/limit [minutes]` in an
|
||||||
|
echo session reports the same event a real driver does, and `/usage` sets
|
||||||
|
what the meter answers — deliberately two commands, because the two
|
||||||
|
disagreeing is the state the whole design is about. The loop was driven end
|
||||||
|
to end that way on 2026-09-05: the wait moved from the dialect's two minutes
|
||||||
|
to the meter's seven when the meter changed its mind, and the message went
|
||||||
|
out on the first check after the meter came back under the limit.
|
||||||
|
|
||||||
|
### Subagents (2026-09-05)
|
||||||
|
|
||||||
|
**A subagent is a second transcript owned by a session, in the same event
|
||||||
|
model, with no process and no controls of its own.** Full design and wire
|
||||||
|
shape in `SUBAGENTS.md`, kept separate because the app half is being built
|
||||||
|
against it in parallel and it is the shared contract between the two. The
|
||||||
|
one-paragraph reason: a session's Task-tool helpers already speak the common
|
||||||
|
event model on the parent's own stdout (each line carrying
|
||||||
|
`parent_tool_use_id`), so giving each one its own small transcript — same
|
||||||
|
file format, same paging routes, same SSE stream, reused by addressing rather
|
||||||
|
than by copying — costs a routing step in the translator and a registry
|
||||||
|
(`session/subagent.rs`) rather than a second session type with a driver, a
|
||||||
|
process and a config entry it does not need.
|
||||||
|
|
||||||
### HTTP surface
|
### HTTP surface
|
||||||
|
|
||||||
**`routes.rs`'s module doc comment is the table.** REST for actions, one SSE
|
**`routes.rs`'s module doc comment is the table.** REST for actions, one SSE
|
||||||
@@ -800,8 +936,9 @@ Noticed and deliberately not fixed, so they are not re-found from scratch.
|
|||||||
|
|
||||||
Phases 1–3 (the skeleton pipe, the full Claude driver, the usage screen) done
|
Phases 1–3 (the skeleton pipe, the full Claude driver, the usage screen) done
|
||||||
2026-08-24. Phase 4 (llama.cpp: model browsing, downloads, and `llama-server`
|
2026-08-24. Phase 4 (llama.cpp: model browsing, downloads, and `llama-server`
|
||||||
through its OpenAI-compatible endpoint) and phase 5 (ssh) done 2026-08-28.
|
through its OpenAI-compatible endpoint) and phase 5 (ssh) done 2026-08-28,
|
||||||
The file explorer and the transcript cache followed in September. What is
|
except for the remote `llama-server` and its port forward, which landed
|
||||||
|
2026-09-04. The file explorer and the transcript cache followed in September. What is
|
||||||
left is real-phone/WireGuard bring-up, which is operational rather than code.
|
left is real-phone/WireGuard bring-up, which is operational rather than code.
|
||||||
|
|
||||||
Each phase ended runnable and verified against the real thing. The backend
|
Each phase ended runnable and verified against the real thing. The backend
|
||||||
+5231
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,496 @@
|
|||||||
|
# How iris should render an unbounded number of images
|
||||||
|
|
||||||
|
## Status (2026-09-04)
|
||||||
|
|
||||||
|
**Implemented**, on the `rustify` branch of `ai-app-2`, in `iris/core` and
|
||||||
|
`iris/src/default/render.rs`. See "Implemented, 2026-09-04" at the bottom for
|
||||||
|
what landed, what differs from the proposal below and why, and what was
|
||||||
|
verified versus merely reasoned about. The short version: the binding array
|
||||||
|
is gone, `request_device` asks for no features and no binding-array limits,
|
||||||
|
and that is now proven on the emulator's software Vulkan
|
||||||
|
(`rigs/gpu-probe`), not just read from the code. `RUST.md`'s blocking item
|
||||||
|
is resolved.
|
||||||
|
|
||||||
|
Iris (the person) asked whether iris's (the library's) approach to
|
||||||
|
"draw however many images happen to be on screen" — relevant here because a
|
||||||
|
transcript can hold an unbounded number of attached screenshots — actually
|
||||||
|
works on mobile, her recollection being that it does not. Checked rather
|
||||||
|
than assumed, on 2026-09-04, on the `rustify` branch of `ai-app-2`. This
|
||||||
|
file is that investigation and the resulting recommendation, written for a
|
||||||
|
second agent to review before anything in iris's render core changes — no
|
||||||
|
code has been written against this yet.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
Every texture iris ever creates — every `Image` widget
|
||||||
|
(`iris/src/widget/image.rs`) and every glyph atlas page — gets a permanent
|
||||||
|
slot in one array via `Textures::add` (`iris/core/src/primitive/texture.rs:65`).
|
||||||
|
Both of iris's texture-sampling primitives (`TEXTURE` and `GLYPH`) read that
|
||||||
|
array by index: `core/src/render/shader.wgsl:56` declares
|
||||||
|
`var views: binding_array<texture_2d<f32>>`, sized by
|
||||||
|
`UiLimits::default()` (`core/src/render/mod.rs:347`) at **100,000 textures,
|
||||||
|
1,000 samplers**. Getting a device to accept that layout needs three wgpu
|
||||||
|
features — `TEXTURE_BINDING_ARRAY`,
|
||||||
|
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
|
||||||
|
`PARTIALLY_BOUND_BINDING_ARRAY` — which correspond to Vulkan's
|
||||||
|
`VK_EXT_descriptor_indexing` ("bindless"), promoted to Vulkan core at 1.2.
|
||||||
|
|
||||||
|
A transcript with an unbounded number of image attachments is exactly the
|
||||||
|
case that grows this array without bound: each attachment becomes its own
|
||||||
|
`Image` widget, which takes its own permanent array slot until dropped.
|
||||||
|
|
||||||
|
## What was measured
|
||||||
|
|
||||||
|
**A new rig, `rigs/gpu-probe`**, asks a device for exactly iris's features
|
||||||
|
and limits with no window and no APK — a plain executable pushed with
|
||||||
|
`adb push` and run from `/data/local/tmp`. It has two parts:
|
||||||
|
`wgpu::Adapter::request_device` with iris's exact `Features`/`Limits`
|
||||||
|
(`src/main.rs`), and a raw Vulkan query bypassing wgpu entirely via `ash`
|
||||||
|
(`src/vk.rs`), to tell "the driver doesn't have it" apart from "wgpu didn't
|
||||||
|
detect it."
|
||||||
|
|
||||||
|
- **On this VM's own GPU** (Vulkan via Venus onto an RX 7900 XT):
|
||||||
|
`IRIS DEVICE: ok`. Not the case that matters — nobody's phone is a
|
||||||
|
discrete desktop GPU — but it is why the design was never checked before
|
||||||
|
now: it always worked in the one place it was tried.
|
||||||
|
- **On the Android emulator's guest Vulkan**, both ICDs it ships
|
||||||
|
(`vk_swiftshader_icd.json` and, cold-booted, `lvp_icd.json`/lavapipe):
|
||||||
|
`request_device` **fails** —
|
||||||
|
`Unsupported features were requested: TEXTURE_BINDING_ARRAY |
|
||||||
|
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
|
||||||
|
PARTIALLY_BOUND_BINDING_ARRAY`. The raw `ash` query on lavapipe shows the
|
||||||
|
driver itself reporting all seven descriptor-indexing sub-features as
|
||||||
|
`true` at device API version 1.3 — so wgpu-hal's own feature detection is
|
||||||
|
being more conservative than the driver here, for a reason not chased
|
||||||
|
further (a likely instance-version negotiation gap, since the extension
|
||||||
|
only promoted to core at 1.2). That part is a wgpu-hal/emulator question,
|
||||||
|
not the finding that matters, and is **not** why this design is rejected.
|
||||||
|
|
||||||
|
**The finding that matters is about real phones, sourced rather than
|
||||||
|
recalled:**
|
||||||
|
|
||||||
|
- The **Android Vulkan Profile 2025** — Google and Khronos's current
|
||||||
|
baseline, covering **80.1% of active Vulkan-capable Android devices** as
|
||||||
|
of October 2025
|
||||||
|
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)) —
|
||||||
|
does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
|
||||||
|
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
|
||||||
|
(indexing by a value uniform across the invocation — Vulkan 1.0 baseline,
|
||||||
|
unrelated to bindless) and stops there; true of the 2021 and 2022
|
||||||
|
profiles as well.
|
||||||
|
- Arm's own developer documentation states **"`VK_EXT_descriptor_indexing`
|
||||||
|
is supported on all Valhall and 5th Gen GPUs"**
|
||||||
|
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
|
||||||
|
Mali generations from roughly 2019 (Mali-G77) onward, with no claim made
|
||||||
|
for Bifrost, Midgard or Utgard, which are still common in budget and
|
||||||
|
older Android phones that are still in daily use.
|
||||||
|
- A search engine's summarized claim of "1% support on Android" for this
|
||||||
|
extension was checked against its cited source (an Arm blog post from
|
||||||
|
2021) and **was not actually there** — that number does not appear in
|
||||||
|
any primary source found and should not be repeated. The baseline-
|
||||||
|
profile finding above is the one with an attributable source; use it
|
||||||
|
instead.
|
||||||
|
|
||||||
|
So this is not a software-renderer artifact. A real, currently-shipping
|
||||||
|
share of the Android fleet lacks the feature iris's texture pipeline asks
|
||||||
|
for unconditionally, and neither the emulator's failure nor the current
|
||||||
|
official hardware baseline gives any reason to expect that to change soon.
|
||||||
|
|
||||||
|
## What growth already costs today, before any redesign
|
||||||
|
|
||||||
|
Checked directly in `core/src/render/mod.rs` and `core/src/render/texture.rs`,
|
||||||
|
because "does this redesign make things worse" needs the current baseline
|
||||||
|
first:
|
||||||
|
|
||||||
|
- The `RenderPipeline` (`UiRenderNode::new`) is created **once** and never
|
||||||
|
rebuilt for any reason related to texture count — its bind group
|
||||||
|
*layouts* declare fixed slot counts (`limits.max_textures`,
|
||||||
|
`limits.max_samplers`) up front and that never changes at runtime. Growth
|
||||||
|
was never at risk of recreating the pipeline, in the current design or
|
||||||
|
any redesign discussed below.
|
||||||
|
- What **does** get rebuilt: `UiRenderNode::update` calls
|
||||||
|
`self.textures.update(&mut ui.textures)`, and if that reports any change,
|
||||||
|
rebuilds `self.rsc_group` — one `BindGroup` whose entries are
|
||||||
|
`BindingResource::TextureViewArray(&tex_manager.views())`, collected
|
||||||
|
fresh over **every currently-live texture**, plus the sampler array and
|
||||||
|
the mask buffer. This happens on every texture `Push`, `Set`, or `Free`
|
||||||
|
— an image added anywhere in the whole app rebuilds one shared structure
|
||||||
|
referencing every other image too.
|
||||||
|
- The one path already excluded from this, on purpose, is a `Patch` —
|
||||||
|
writing into an existing texture's pixels without changing which
|
||||||
|
textures exist. The code says why directly
|
||||||
|
(`core/src/render/texture.rs`, in `GpuTextures::update`): *"A patch
|
||||||
|
changes texture contents, not the binding array, so it must not report
|
||||||
|
`changed` — rebuilding the bind group per glyph is the cost this exists
|
||||||
|
to avoid."* This is exactly the mechanism I1 built for the glyph atlas:
|
||||||
|
growing an existing atlas page costs a `write_texture` into a sub-rect,
|
||||||
|
nothing else.
|
||||||
|
|
||||||
|
So today, growth that stays inside an existing texture (glyphs added to an
|
||||||
|
atlas page) is already free. Growth that adds a *new* texture — a new atlas
|
||||||
|
page, or any standalone image — already rebuilds the one shared array
|
||||||
|
regardless of how the array is populated, before any change discussed
|
||||||
|
below. That existing cost is O(live texture count) in CPU work to collect
|
||||||
|
the view list and in however expensive the driver finds a
|
||||||
|
descriptor-set-sized-for-N-descriptors to be.
|
||||||
|
|
||||||
|
## Prior art, checked rather than assumed
|
||||||
|
|
||||||
|
Two independent projects were checked to see whether "atlas for images"
|
||||||
|
is actually how this is normally done, rather than a guess:
|
||||||
|
|
||||||
|
- **egui_wgpu** (`crates/egui-wgpu/src/renderer.rs` in emilk/egui), the
|
||||||
|
closest prior art to iris — an immediate-mode wgpu-backed UI library that
|
||||||
|
ships on Android. It keeps a `HashMap<TextureId, Texture>` and gives
|
||||||
|
**each texture its own ordinary `BindGroup`** — one texture, one sampler,
|
||||||
|
no array, no descriptor indexing of any kind. Draw calls are batched by
|
||||||
|
texture id and the bind group is switched between batches within the
|
||||||
|
render pass.
|
||||||
|
- **Vello** — the renderer Masonry (E1/E2's Linebender stack) draws
|
||||||
|
through — hit the identical problem and wrote down why in their own
|
||||||
|
roadmap document
|
||||||
|
([github.com/linebender/vello/blob/main/doc/roadmap_2023.md](https://github.com/linebender/vello/blob/main/doc/roadmap_2023.md)):
|
||||||
|
*"The number of images that may appear in a scene is not bounded, which
|
||||||
|
is not a good fit for the basic descriptor binding model... Until then,
|
||||||
|
we'll do a workaround of having a single atlas image containing all the
|
||||||
|
images in the scene."* Their reason is broader than Android — WebGPU 1.0
|
||||||
|
has no descriptor indexing at all — but it reaches the same conclusion
|
||||||
|
for the same shape of problem: atlas, not a bigger bindless array.
|
||||||
|
|
||||||
|
**This is also a live hazard, not a solved one.** Vello's own changelog
|
||||||
|
(Sparse Strips v0.2.0) lists a fix titled *"WebGL image-atlas allocation
|
||||||
|
and growth on Mali-G52 GPUs, avoiding application-not-responding errors"*
|
||||||
|
— an actual ANR, from atlas growth, on an actual mid-range Android GPU,
|
||||||
|
in the renderer Masonry is built on. The same release added
|
||||||
|
`AtlasSpaceDiagnostics`/`AtlasLayerDiagnostics` (per-layer free-space,
|
||||||
|
utilization, fragmentation) because growth needed instrumenting in
|
||||||
|
production, not because it turned out to be free.
|
||||||
|
|
||||||
|
## Recommendation (not yet implemented)
|
||||||
|
|
||||||
|
1. **Small, plentiful textures** — glyphs (already done, I1), thumbnails,
|
||||||
|
downscaled attachment previews, icons — go through a shared atlas, the
|
||||||
|
same technique as `core/src/render/atlas.rs` generalized beyond glyphs.
|
||||||
|
Adding one to an existing page is a `Patch`, already free per the
|
||||||
|
section above.
|
||||||
|
2. **Large or one-off images** — a photo attachment opened at full
|
||||||
|
resolution, anything that would fragment a shared page — get their
|
||||||
|
**own ordinary, non-array bind group**, the egui_wgpu way. Creating one
|
||||||
|
is O(1): it references only itself, and does not touch any other
|
||||||
|
texture's binding, unlike today's shared array where every push
|
||||||
|
rebuilds a structure listing everything.
|
||||||
|
3. **Opening a new atlas page** is the one case that still resembles
|
||||||
|
today's rebuild — infrequent (bounded by how many *pages* are needed,
|
||||||
|
not by how many images have ever been attached) but not free, and
|
||||||
|
Vello's Mali-G52 fix says this specifically deserves care: it should
|
||||||
|
never be allowed to block a frame, and it is worth having the
|
||||||
|
equivalent of Vello's atlas diagnostics before trusting it under load.
|
||||||
|
4. **Net effect**: dropping `TEXTURE_BINDING_ARRAY`,
|
||||||
|
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`, and
|
||||||
|
`PARTIALLY_BOUND_BINDING_ARRAY` from iris's device request entirely.
|
||||||
|
Every path above is plain Vulkan 1.0 / GLES-level texture sampling.
|
||||||
|
This is also what fixes the emulator failure measured above, regardless
|
||||||
|
of the unresolved wgpu-hal question: a device that never asks for the
|
||||||
|
feature cannot be refused for lacking it.
|
||||||
|
|
||||||
|
## What this touches, and what is still open
|
||||||
|
|
||||||
|
Implementing this reworks iris's rendering core: the shader's binding
|
||||||
|
group layout (`shader.wgsl`), `Textures` and `GpuTextures`
|
||||||
|
(`core/src/primitive/texture.rs`, `core/src/render/texture.rs`), both
|
||||||
|
texture-sampling primitives, and `core/src/ui/painter.rs`'s draw-call
|
||||||
|
batching (today one draw call can reference any texture by index; the
|
||||||
|
per-texture-bind-group path needs draws grouped by which bind group they
|
||||||
|
use). Nothing has been started.
|
||||||
|
|
||||||
|
Open questions a reviewer should weigh in on:
|
||||||
|
|
||||||
|
- **The size threshold** between "goes in an atlas page" and "gets its own
|
||||||
|
bind group." Too low and ordinary attachment thumbnails end up as
|
||||||
|
one-off bind groups, losing the batching benefit the atlas exists for;
|
||||||
|
too high and a page fragments on a handful of medium images.
|
||||||
|
- **Eviction policy** for atlas pages once the working set does not fit —
|
||||||
|
today's `GlyphAtlas` never evicts, because a font's glyph set is small
|
||||||
|
and bounded; images are not. An LRU at the page level, or at the
|
||||||
|
individual-image level within a page, has not been designed.
|
||||||
|
- **Whether iris should keep any binding array at all**, even a small
|
||||||
|
fixed one (say, capped at a few dozen slots) for atlas pages themselves,
|
||||||
|
or whether every atlas page should also be its own ordinary bind group
|
||||||
|
like standalone images — the array's only remaining justification would
|
||||||
|
be avoiding a bind-group-per-draw-call switch cost that has not been
|
||||||
|
measured on this project's actual target hardware.
|
||||||
|
- **How this interacts with I2/E2's virtualised list** (I3): a
|
||||||
|
bottom-anchored transcript composes only visible rows, so the live
|
||||||
|
texture set should already be bounded by what is on screen rather than
|
||||||
|
by the whole conversation — worth confirming that invariant holds before
|
||||||
|
relying on it to keep atlas/bind-group churn small.
|
||||||
|
|
||||||
|
## Review, 2026-09-04
|
||||||
|
|
||||||
|
A second pass over the file above against the code, done before anything
|
||||||
|
is implemented. Iris's worry going in: a bind group per texture means a
|
||||||
|
draw call per image, and she wants this as efficient as it can be.
|
||||||
|
|
||||||
|
### What checked out
|
||||||
|
|
||||||
|
Every code reference above is accurate as of this commit: the 100,000 /
|
||||||
|
1,000 limits, the one-time pipeline, the `rsc_group` rebuild on every
|
||||||
|
`Push`/`Set`/`Free`, and the `Patch` exclusion. The device request that
|
||||||
|
asks for the three features is `iris/src/default/render.rs:96`, which the
|
||||||
|
text above does not name. egui-wgpu and Vello are described correctly.
|
||||||
|
|
||||||
|
### The emulator refusal is a wgpu-hal gap, now located
|
||||||
|
|
||||||
|
The file guessed "a likely instance-version negotiation gap." It is
|
||||||
|
narrower than that and it is in wgpu-hal, not the emulator. wgpu-hal
|
||||||
|
28.0.0 (`src/vulkan/adapter.rs:1618`) only queries
|
||||||
|
`PhysicalDeviceDescriptorIndexingFeaturesEXT` **when the device advertises
|
||||||
|
the `VK_EXT_descriptor_indexing` extension string**. A Vulkan 1.2+ driver
|
||||||
|
that has descriptor indexing as core need not list the extension, and
|
||||||
|
lavapipe at 1.3 evidently does not, so wgpu never asks and reports the
|
||||||
|
features absent, which is why `ash` sees seven `true`s and wgpu sees none.
|
||||||
|
The properties query beside it (line 1486) correctly accepts
|
||||||
|
`device_api_version >= 1.2 || extension`; the features query does not.
|
||||||
|
wgpu-hal 30.0.1 in the local registry has the same asymmetry (lines
|
||||||
|
1872 and 2036). Worth an upstream issue, but not a reason to keep the
|
||||||
|
design: on real phones the gate that matters is stricter still.
|
||||||
|
|
||||||
|
**wgpu's `TEXTURE_BINDING_ARRAY` needs six sub-features, not one**
|
||||||
|
(`adapter.rs:160-177`): non-uniform indexing *and* update-after-bind for
|
||||||
|
sampled images, storage images and storage buffers, all together, because
|
||||||
|
wgpu marks every array-bearing descriptor set update-after-bind. So Arm's
|
||||||
|
"the extension is supported on Valhall" is necessary but not sufficient;
|
||||||
|
a driver with sampled-image indexing and without storage-buffer
|
||||||
|
update-after-bind is refused too. That widens the excluded set beyond
|
||||||
|
what the Arm quote suggests and strengthens the conclusion.
|
||||||
|
|
||||||
|
### A live bug in the current code, found on the way
|
||||||
|
|
||||||
|
`GpuTextures::update` (`core/src/render/texture.rs:33`) implements
|
||||||
|
"a patch must not report changed" as `changed = false`, unconditionally,
|
||||||
|
which also **cancels a `Push` earlier in the same batch**. That ordering is
|
||||||
|
exactly what opening a new atlas page produces: `GlyphAtlas::allocate`
|
||||||
|
pushes the page and `insert` patches it in the same frame, so the bind
|
||||||
|
group is not rebuilt and the new page's view is not bound until some
|
||||||
|
unrelated texture change happens to rebuild it. It is hidden today only
|
||||||
|
because the masks path also sets `changed`. The fix is one line
|
||||||
|
(`changed |= !matches!(update, Patch)` in spirit); it should go in with
|
||||||
|
the redesign since that code is being replaced, and it is recorded here
|
||||||
|
so it is not rediscovered.
|
||||||
|
|
||||||
|
### In-layer draw order is already undefined
|
||||||
|
|
||||||
|
Relevant to any batching redesign: `Primitives::apply_free`
|
||||||
|
(`core/src/render/primitive.rs:147`) uses `swap_remove`, so the instance
|
||||||
|
order within a layer is permuted whenever anything is freed. Overlap order
|
||||||
|
inside one layer is therefore not something the renderer promises today;
|
||||||
|
ordering is done with layers. That means grouping a layer's draws by
|
||||||
|
texture, or drawing a layer's images after its rects and glyphs, loses
|
||||||
|
nothing that currently exists. It should be written down as an invariant
|
||||||
|
when the redesign lands, because the new code will depend on it.
|
||||||
|
|
||||||
|
### On "a draw call per image"
|
||||||
|
|
||||||
|
Two corrections to the worry. First, it is a draw per *distinct texture per
|
||||||
|
layer*, not per image primitive: every glyph quad in a layer shares the
|
||||||
|
atlas and stays one instanced draw, and a thumbnail atlas would do the same
|
||||||
|
for previews. Second, the count is bounded by what is on screen, which I3's
|
||||||
|
virtualised transcript already bounds, and a mobile GPU is not draw-call
|
||||||
|
bound at tens of draws per frame; egui ships exactly this on Android. What
|
||||||
|
does cost is per-frame *bind group creation* and per-frame *sorting*, and
|
||||||
|
the current code already creates a `primitive_group` bind group every time
|
||||||
|
a layer updates (`render/mod.rs:103`), so one more per new image is not a
|
||||||
|
regression in kind.
|
||||||
|
|
||||||
|
### Recommended shape (proposal, for Iris to accept or change)
|
||||||
|
|
||||||
|
Aimed at the fewest moving parts that need no feature beyond Vulkan 1.0:
|
||||||
|
|
||||||
|
1. **Atlas pages become layers of one `texture_2d_array`**, not separate
|
||||||
|
textures. Every page is already `PAGE`x`PAGE` RGBA8, which is the one
|
||||||
|
constraint an array texture imposes. A layer index is an ordinary
|
||||||
|
sampling operand in WGSL and needs no indexing feature, so `GLYPH`
|
||||||
|
(and any future atlased-image primitive) carries a layer instead of a
|
||||||
|
`view_idx` and all of a layer's text stays **one draw**. This answers
|
||||||
|
the open question above about keeping a small binding array: no. Cost
|
||||||
|
of opening a page: recreate the array with one more layer and
|
||||||
|
`copy_texture_to_texture` the old ones, GPU-side, no readback; grow
|
||||||
|
with headroom (double) so it is rare. wgpu's default
|
||||||
|
`max_texture_array_layers` is 256, at 4 MB each, so the cap is memory
|
||||||
|
rather than the API.
|
||||||
|
2. **Every standalone image is its own texture with its own bind group**,
|
||||||
|
and its instances live in a **separate per-layer instance list**, not
|
||||||
|
the main one. Then the main instance buffer never contains an image,
|
||||||
|
there is nothing to sort, no handle remapping beyond what
|
||||||
|
`apply_free` already does, and each image is `draw(0..4, k..k+1)` with
|
||||||
|
its bind group set first. Group 2's layout becomes `{atlas array,
|
||||||
|
one image texture, sampler, masks}`; the main draw binds a 1x1 null
|
||||||
|
image in the image slot, each image draw binds its own. One pipeline,
|
||||||
|
one shader, one layout.
|
||||||
|
3. **No thumbnail atlas in the first version.** With images on their own
|
||||||
|
textures, the threshold and eviction questions above disappear: an
|
||||||
|
image is freed when the row that owns its `TextureHandle` scrolls out.
|
||||||
|
Add an image atlas only if a measured screen shows enough small images
|
||||||
|
to matter, which a transcript rarely does.
|
||||||
|
4. **Drop the three features and the two `max_binding_array_*` limits from
|
||||||
|
`src/default/render.rs`**, and the `UiLimits` counts with them.
|
||||||
|
5. **Sampling is `NonFiltering` today** (`render/mod.rs:290,299`), so a
|
||||||
|
downscaled attachment will alias. Either request a filtering sampler
|
||||||
|
for the image slot or downscale on the CPU before upload; decide when
|
||||||
|
the image widget is touched, not as part of this.
|
||||||
|
|
||||||
|
What this costs against the file's original recommendation: `Textures`
|
||||||
|
needs to know an image from a page (two kinds of handle, or a kind on
|
||||||
|
`TextureHandle`), and `Primitives` gets a second instance list per layer.
|
||||||
|
What it saves: the sort, the size threshold, the eviction policy, and any
|
||||||
|
per-page bind group switch.
|
||||||
|
|
||||||
|
## Implemented, 2026-09-04
|
||||||
|
|
||||||
|
The shape above, built as proposed with one structural addition the proposal
|
||||||
|
didn't need to spell out and one bug it predicted made moot rather than
|
||||||
|
literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`,
|
||||||
|
`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`),
|
||||||
|
`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`),
|
||||||
|
`core/src/render/atlas.rs`, `core/src/ui/painter.rs`,
|
||||||
|
`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed),
|
||||||
|
`core/src/render/shader.wgsl`, `src/default/render.rs`, and
|
||||||
|
`rigs/gpu-probe/src/main.rs`.
|
||||||
|
|
||||||
|
**1. Atlas pages as array layers.** `GpuTextures` owns one
|
||||||
|
`texture_2d_array` (`array_texture`/`array_view`), grown by doubling
|
||||||
|
(`grow_array`): a new texture is created at twice the layer capacity, the
|
||||||
|
old layers are copied across with `copy_texture_to_texture` (GPU-side, no
|
||||||
|
readback), and every bind group that referenced the old view — the main
|
||||||
|
one and every live standalone image's — is rebuilt, since the view's
|
||||||
|
identity changed. `GlyphPrimitive` carries `layer: u32` instead of
|
||||||
|
`view_idx`/`sampler_idx`; the layer number is assigned synchronously in
|
||||||
|
`Textures::add_page` (a plain counter, `next_page_layer`), not by the
|
||||||
|
renderer, because `GlyphAtlas::insert` needs it in the same call, before
|
||||||
|
any GPU sync happens — the renderer only finds out later, when it
|
||||||
|
processes the queued `Push`.
|
||||||
|
|
||||||
|
**2. Standalone images, one bind group each.** `TextureKind` on
|
||||||
|
`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index,
|
||||||
|
`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list
|
||||||
|
— `images: Vec<PrimitiveInstance>`, tagged `IMAGE_BINDING` — separate from
|
||||||
|
`instances` (rects and glyphs), written by `Painter::write_image` rather
|
||||||
|
than through the generic `Primitive` trait, since an image has nowhere in
|
||||||
|
`PrimitiveData` to put a per-instance entry once the bind group already
|
||||||
|
picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer
|
||||||
|
once as before, then walks `image_instance` one entry at a time, binding
|
||||||
|
that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing
|
||||||
|
`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed
|
||||||
|
`{atlas array, one image texture, sampler, masks}`; the main draw binds a
|
||||||
|
1x1 null view in the image slot.
|
||||||
|
|
||||||
|
**The one addition beyond the proposal**: the masks storage buffer lives
|
||||||
|
in every per-image bind group (group 2, binding 3), and `ArrBuf<Mask>`
|
||||||
|
recreates its buffer whenever the mask count changes size
|
||||||
|
(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized).
|
||||||
|
A resize invalidates every bind group holding the old buffer, not just the
|
||||||
|
main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls
|
||||||
|
`rebuild_image_bind_groups` when it's set, alongside the same rebuild the
|
||||||
|
array-growth path already needed. This wasn't a design question the
|
||||||
|
proposal had to answer (it treated bind-group construction as a given),
|
||||||
|
but it's exactly the shape of trap layer growth already had, so it uses
|
||||||
|
the same fix.
|
||||||
|
|
||||||
|
**3. No thumbnail atlas.** Not built, as proposed.
|
||||||
|
|
||||||
|
**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`,
|
||||||
|
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from
|
||||||
|
`src/default/render.rs`'s `request_device`, and `UiLimits` (the type
|
||||||
|
itself, not just its binding-array methods — once its two fields were
|
||||||
|
gone there was nothing left in it, and `UiRenderNode::new` no longer takes
|
||||||
|
a limits parameter). `binding_array` no longer appears anywhere in
|
||||||
|
`shader.wgsl`.
|
||||||
|
|
||||||
|
**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own
|
||||||
|
note that this is a separate decision for whenever the image widget itself
|
||||||
|
is touched.
|
||||||
|
|
||||||
|
**The `changed = false` bug is structurally gone, not patched.** The old
|
||||||
|
`GpuTextures::update` held one `changed: bool` that a `Patch` reset
|
||||||
|
unconditionally, which could erase an earlier `Push` in the same batch (a
|
||||||
|
new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s
|
||||||
|
`Patch`, both queued before the renderer ever runs). The new `update`
|
||||||
|
computes the rebuild signal by OR-ing each event's own answer
|
||||||
|
(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never
|
||||||
|
contributes to it — there is no shared mutable flag left for a `Patch` to
|
||||||
|
stomp on. Documented at the call site
|
||||||
|
(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the
|
||||||
|
`Patch` match arm's comment) rather than fixed as a one-line diff, since
|
||||||
|
the mechanism that could go wrong no longer exists.
|
||||||
|
|
||||||
|
**In-layer draw order is an explicit invariant now, not just a fact about
|
||||||
|
`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its
|
||||||
|
rects and glyphs, and `Primitives::apply_free`'s doc comment states
|
||||||
|
directly that both of a layer's lists (`instances` and `images`) free with
|
||||||
|
`swap_remove` and that nothing may assume adjacency survives a free —
|
||||||
|
recorded there because `apply_free` is the one place a change to either
|
||||||
|
list's ordering would have to be reconciled.
|
||||||
|
|
||||||
|
**Verified:**
|
||||||
|
|
||||||
|
- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
|
||||||
|
`cargo clippy --all-targets`, `cargo test --workspace` all clean in
|
||||||
|
`iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass
|
||||||
|
(unchanged from I1; nothing here is pure-logic enough to add a unit
|
||||||
|
test to — it's all GPU resource wiring).
|
||||||
|
- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and
|
||||||
|
`iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly
|
||||||
|
on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every
|
||||||
|
panel, confirming `GlyphPrimitive.layer` addresses the array correctly.
|
||||||
|
- The standalone-image path specifically: a throwaway example (not
|
||||||
|
committed) with an `image(...)` widget as part of the root, run the same
|
||||||
|
way, rendered the image next to glyph-atlas text in one frame —
|
||||||
|
confirming a live `BindGroup` built by `GpuTextures::create_image` and
|
||||||
|
bound per-`draw()` call actually samples the right texture. `tabs`'s own
|
||||||
|
"image span" tab exercises the same widget but needs a click to reach,
|
||||||
|
which the headless compositor can't deliver (no seat devices, per I1's
|
||||||
|
own note on this file) — the throwaway example is what stood in for it.
|
||||||
|
- **Exercised, 2026-09-04: `grow_array` under real load, on `tabs`.**
|
||||||
|
Rather than building a purpose-made glyph flood, `PAGE`
|
||||||
|
(`core/src/render/atlas.rs`) was temporarily dropped from 1024 to 64 —
|
||||||
|
small enough that `tabs`'s ordinary mix of sizes and families (nothing
|
||||||
|
exotic: a handful of `Text` widgets at a few sizes, one at
|
||||||
|
`Family::Monospace`) already exceeds one page's worth of distinct
|
||||||
|
glyphs. A one-line `eprintln!` in `grow_array` confirmed two real grows
|
||||||
|
in a single run (`GROW_ARRAY: 1 -> 2` then `GROW_ARRAY: 2 -> 4`, i.e.
|
||||||
|
glyphs landed on at least a third layer), and
|
||||||
|
`iris/run-headless.sh tabs --shot` showed every tab's text rendering
|
||||||
|
correctly with no corruption or missing glyphs — confirming the
|
||||||
|
`copy_texture_to_texture` grow-and-relocate path and cross-layer
|
||||||
|
sampling (`GlyphPrimitive.layer` addressing a layer beyond the first)
|
||||||
|
both work. Command:
|
||||||
|
`sed -i 's/PAGE: u32 = 1024/PAGE: u32 = 64/' core/src/render/atlas.rs`,
|
||||||
|
rebuild, `./run-headless.sh tabs --shot /tmp/x.png`, then
|
||||||
|
`git checkout -- core/src/render/atlas.rs` to revert — this is a
|
||||||
|
throwaway diagnostic value, never a committed change, since a real
|
||||||
|
1024px page holding only a handful of glyphs at a time would be mostly
|
||||||
|
wasted space in normal use. Confirmed the revert left `tabs` and
|
||||||
|
`minimal` byte-identical to the pre-check screenshots afterward.
|
||||||
|
- **The decisive check**, `rigs/gpu-probe` rewritten to request iris's new
|
||||||
|
(empty) feature/limit set and run on this checkout's own emulator
|
||||||
|
(`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets
|
||||||
|
a real Vulkan device (SwiftShader) rather than the `-gpu host` default,
|
||||||
|
which disables Vulkan in this VM entirely (`-feature -Vulkan`, because
|
||||||
|
gfxstream can't pair Venus with the real GPU here — worth remembering,
|
||||||
|
since the *default* `emu up` gives a device with **no** Vulkan adapter
|
||||||
|
at all, which reads exactly like the old bindless failure if you don't
|
||||||
|
know to ask for `EMU_GPU=software`):
|
||||||
|
|
||||||
|
cd rigs/gpu-probe
|
||||||
|
ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \
|
||||||
|
cargo ndk -t arm64-v8a -P 26 build --release
|
||||||
|
EMU_GPU=software emu up # from ~/repos/emulator-tools
|
||||||
|
adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/
|
||||||
|
adb shell chmod 755 /data/local/tmp/gpu-probe
|
||||||
|
adb shell /data/local/tmp/gpu-probe
|
||||||
|
|
||||||
|
Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`,
|
||||||
|
`features iris requires:` (none listed — the set is empty),
|
||||||
|
`max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix
|
||||||
|
measured working, on the exact rig that first measured it failing.
|
||||||
|
Emulator stopped afterward (`emu down`); nothing was left running.
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,73 @@
|
|||||||
|
# Compose bench report from Iris's phone, 2026-09-06
|
||||||
|
|
||||||
|
The Compose half of P0 (RUST.md), run by Iris on her own phone and pasted
|
||||||
|
back verbatim. The iris half's report goes beside it in this directory
|
||||||
|
when it exists. Her caveat, worth keeping with the numbers: "I don't think
|
||||||
|
this is entirely fair because the UI for iris is more minimal" -- the
|
||||||
|
Compose screen also draws the usage bar, the status row and tool cards,
|
||||||
|
which the iris bench screen does not yet. Her impression of the iris build
|
||||||
|
before its first-touch bug: "it already feels very smooth so far".
|
||||||
|
|
||||||
|
What to read first: the phone runs at 120 Hz, so the budget is 8.3 ms;
|
||||||
|
`late` is measured against that. Compose's tail is the streaming phase --
|
||||||
|
`markdown reparsed while streaming: 396, 8.5ms mean, 25.8ms worst` and
|
||||||
|
`record: one block: 398, 6.3ms mean, 19.9ms worst` -- which is exactly the
|
||||||
|
path iris's `TranscriptScreen::apply` (replace the last row only) is meant
|
||||||
|
to beat. Process CPU over the run is 20.9 s of a 38.5 s run; peak RSS
|
||||||
|
587 MB; battery current mean 419 mA.
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-app render report
|
||||||
|
device: Pixel 9 Pro XL (Google), Android 17
|
||||||
|
build: release
|
||||||
|
|
||||||
|
transcript:
|
||||||
|
43 events, 41 rows, 93 units loaded
|
||||||
|
viewport 1333px, 2 units visible
|
||||||
|
on screen: the list's own 0px, AssistantMsg 24520px
|
||||||
|
0 tool calls and 0 groups open
|
||||||
|
|
||||||
|
frames:
|
||||||
|
1613 frames over 38.5s at 120Hz (8.3ms budget)
|
||||||
|
late: 742 (46.0%)
|
||||||
|
total p50 7.7ms p90 29.2ms p99 41.1ms
|
||||||
|
waited p50 0.5ms p90 12.9ms p99 27.2ms
|
||||||
|
input p50 0.0ms p90 0.0ms p99 0.0ms
|
||||||
|
anim p50 1.1ms p90 5.8ms p99 9.5ms
|
||||||
|
layout p50 0.0ms p90 0.1ms p99 0.2ms
|
||||||
|
draw p50 0.4ms p90 15.9ms p99 27.9ms
|
||||||
|
sync p50 0.1ms p90 0.5ms p99 1.0ms
|
||||||
|
issue p50 1.1ms p90 1.7ms p99 3.0ms
|
||||||
|
swap p50 0.4ms p90 0.5ms p99 0.7ms
|
||||||
|
gpu p50 1.8ms p90 2.1ms p99 6.6ms
|
||||||
|
|
||||||
|
where the draw phase went:
|
||||||
|
draw phase 3.83ms per frame, of which:
|
||||||
|
the transcript: 0.25ms (measure 0.15, place 0.10, record 0.00)
|
||||||
|
everything else: 3.58ms (93%)
|
||||||
|
|
||||||
|
work since this was last copied:
|
||||||
|
draw: the whole transcript: 12, 0.2ms total, 0.0ms mean, 0.0ms worst
|
||||||
|
grouped tool runs: 398, 10.3ms total, 0.0ms mean, 0.1ms worst
|
||||||
|
markdown cut into pieces: 1, 0.0ms total, 0.0ms mean, 0.0ms worst
|
||||||
|
markdown parsed while composing: 7, 3.0ms total, 0.4ms mean, 0.5ms worst
|
||||||
|
markdown ready: 46
|
||||||
|
markdown reparsed while streaming: 396, 3384.6ms total, 8.5ms mean, 25.8ms worst
|
||||||
|
markdown warmed: 1, 1.4ms total, 1.4ms mean, 1.4ms worst
|
||||||
|
measure: the whole transcript: 957, 248.7ms total, 0.3ms mean, 15.7ms worst
|
||||||
|
message composed: 403
|
||||||
|
message cut into parts: 1, 0.1ms total, 0.1ms mean, 0.1ms worst
|
||||||
|
place: the whole transcript: 1319, 162.4ms total, 0.1ms mean, 1.3ms worst
|
||||||
|
record: one block: 398, 2498.7ms total, 6.3ms mean, 19.9ms worst
|
||||||
|
session screen recomposed: 413
|
||||||
|
status row recomposed: 1
|
||||||
|
unit composed: 538
|
||||||
|
units flattened: 399, 44.3ms total, 0.1ms mean, 0.5ms worst
|
||||||
|
usage bar recomposed: 413
|
||||||
|
|
||||||
|
bench:
|
||||||
|
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||||||
|
process CPU time over this run: 20907ms
|
||||||
|
peak RSS: 587356kB
|
||||||
|
battery current: mean -418509µA over 39 samples (min -1988281, max -107812)
|
||||||
|
```
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Compose bench v2 report from Iris's phone, 2026-09-06
|
||||||
|
|
||||||
|
Bench v2 (fling / stream / type / keyboard, RUST.md's P0 box) on the
|
||||||
|
Compose `bench` build, run by Iris on her Pixel 9 Pro XL, verbatim. Note
|
||||||
|
the display was at **60 Hz** for this run (16.7 ms budget) where the v1
|
||||||
|
run was at 120 Hz -- the phone's adaptive refresh rate decides, and
|
||||||
|
`late` is judged against whichever it was, so compare a run with a run at
|
||||||
|
the same rate. The iris v2 report goes beside this when it exists.
|
||||||
|
|
||||||
|
What it says: fling, type and keyboard are all essentially clean on
|
||||||
|
Compose (0.1%, 0.9% and 0% late; fling p50 5.5 ms, p99 11.6 ms). The
|
||||||
|
whole tail is the streaming phase again -- 41.9% late, p99 42.5 ms,
|
||||||
|
driven by `markdown reparsed while streaming` (8.6 ms mean, 30.3 ms
|
||||||
|
worst) and `record: one block` (6.3 ms mean, 25.5 ms worst). Process CPU
|
||||||
|
69.6 s over the 125.5 s run; peak RSS 577 MB; battery current mean
|
||||||
|
571 mA over 126 samples.
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-app render report
|
||||||
|
device: Pixel 9 Pro XL (Google), Android 17
|
||||||
|
build: release
|
||||||
|
|
||||||
|
transcript:
|
||||||
|
108 events, 26 rows, 58 units loaded
|
||||||
|
viewport 1531px, 2 units visible
|
||||||
|
on screen: the list's own 0px, AssistantMsg 24520px
|
||||||
|
0 tool calls and 0 groups open
|
||||||
|
|
||||||
|
per phase:
|
||||||
|
fling: 3278 frames over 32.7s
|
||||||
|
late: 4 (0.1%)
|
||||||
|
total p50 5.5ms p90 8.7ms p99 11.6ms
|
||||||
|
worst 49.0ms
|
||||||
|
stream: 1041 frames over 21.3s
|
||||||
|
late: 436 (41.9%)
|
||||||
|
total p50 13.4ms p90 31.7ms p99 42.5ms
|
||||||
|
worst 52.5ms
|
||||||
|
type: 2446 frames over 61.5s
|
||||||
|
late: 23 (0.9%)
|
||||||
|
total p50 7.3ms p90 13.2ms p99 16.5ms
|
||||||
|
worst 38.9ms
|
||||||
|
keyboard: 358 frames over 10.0s
|
||||||
|
late: 0 (0.0%)
|
||||||
|
total p50 6.3ms p90 8.6ms p99 11.1ms
|
||||||
|
worst 12.0ms
|
||||||
|
|
||||||
|
frames:
|
||||||
|
7122 frames over 125.5s at 60Hz (16.7ms budget)
|
||||||
|
late: 463 (6.5%)
|
||||||
|
total p50 6.0ms p90 13.8ms p99 34.0ms
|
||||||
|
waited p50 0.5ms p90 1.1ms p99 19.9ms
|
||||||
|
input p50 0.0ms p90 0.0ms p99 0.0ms
|
||||||
|
anim p50 0.7ms p90 4.5ms p99 7.6ms
|
||||||
|
layout p50 0.1ms p90 0.1ms p99 0.2ms
|
||||||
|
draw p50 0.7ms p90 2.9ms p99 21.9ms
|
||||||
|
sync p50 0.1ms p90 0.2ms p99 0.6ms
|
||||||
|
issue p50 1.4ms p90 2.4ms p99 3.2ms
|
||||||
|
swap p50 0.4ms p90 0.8ms p99 1.2ms
|
||||||
|
gpu p50 1.5ms p90 2.1ms p99 6.6ms
|
||||||
|
|
||||||
|
where the draw phase went:
|
||||||
|
draw phase 1.74ms per frame, of which:
|
||||||
|
the transcript: 0.24ms (measure 0.10, place 0.14, record 0.00)
|
||||||
|
everything else: 1.51ms (86%)
|
||||||
|
|
||||||
|
work since this was last copied:
|
||||||
|
draw: the whole transcript: 280, 1.9ms total, 0.0ms mean, 0.0ms worst
|
||||||
|
grouped tool runs: 407, 18.6ms total, 0.0ms mean, 0.2ms worst
|
||||||
|
markdown cut into pieces: 40, 0.4ms total, 0.0ms mean, 0.0ms worst
|
||||||
|
markdown parsed while composing: 2, 1.6ms total, 0.8ms mean, 1.1ms worst
|
||||||
|
markdown ready: 323
|
||||||
|
markdown reparsed while streaming: 395, 3406.9ms total, 8.6ms mean, 30.3ms worst
|
||||||
|
markdown warmed: 40, 38.2ms total, 1.0ms mean, 4.3ms worst
|
||||||
|
measure: the whole transcript: 1978, 683.9ms total, 0.3ms mean, 15.9ms worst
|
||||||
|
message composed: 397
|
||||||
|
message cut into parts: 40, 2.9ms total, 0.1ms mean, 0.2ms worst
|
||||||
|
place: the whole transcript: 4308, 996.0ms total, 0.2ms mean, 2.4ms worst
|
||||||
|
record: one block: 394, 2472.7ms total, 6.3ms mean, 25.5ms worst
|
||||||
|
session screen recomposed: 1630
|
||||||
|
status row recomposed: 1
|
||||||
|
transcript page from server: 10
|
||||||
|
unit composed: 927
|
||||||
|
units flattened: 408, 85.5ms total, 0.2ms mean, 1.8ms worst
|
||||||
|
|
||||||
|
bench:
|
||||||
|
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=0/off=0px outward=idx=188/off=182px end=idx=0/off=0px
|
||||||
|
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||||
|
type: 600 characters inserted then deleted, one per 50ms
|
||||||
|
keyboard: shown 5/5, hidden 5/5 (confirmed via isImeVisible)
|
||||||
|
process CPU time over this run: 69564ms
|
||||||
|
peak RSS: 577452kB
|
||||||
|
battery current: mean -571483µA over 126 samples (min -2361718, max -99218)
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# iris bench report from Iris's phone, 2026-09-06, before the phone fixes
|
||||||
|
|
||||||
|
Build 46246ea (Vulkan, bench v1: 24-swipe scroll loop then 400 streamed
|
||||||
|
events), run by Iris on her Pixel 9 Pro XL before the first-touch wipe,
|
||||||
|
the missing bold faces, the density scale and the status-bar inset were
|
||||||
|
fixed -- so the rows were drawn at roughly a third of their intended size
|
||||||
|
and the run may have included frames after the wipe. Preliminary, kept
|
||||||
|
because it is the first iris number from real hardware. Compare with
|
||||||
|
`compose-phone-2026-09-06.md`, taken on the same phone with the same
|
||||||
|
fixture and gesture loop.
|
||||||
|
|
||||||
|
Reading it: the phone is 120 Hz (8.3 ms budget). `janky%` here counts
|
||||||
|
frames over 16.7 ms, so it is not Compose's `late` (over 8.3 ms). Like for
|
||||||
|
like: iris p50 6.2 ms vs Compose 7.7 ms; p90 32.0 vs 29.2; p99 42.1 vs
|
||||||
|
41.1. `cpu_p50=4.7ms` is iris's own per-frame CPU work on the phone,
|
||||||
|
against 0.2-0.4 ms on the emulator's x86 cores. Process CPU 15.6 s vs
|
||||||
|
20.9 s, but over a shorter run (692 frames vs 1613 -- iris only renders on
|
||||||
|
change and had no fling settle time), so per-second CPU is not directly
|
||||||
|
comparable; peak RSS 365 MB vs 587 MB. Battery current mean 563 mA vs
|
||||||
|
419 mA is the one figure that reads worse, and it is the least
|
||||||
|
comparable: 22 samples vs 39, over runs of different length and different
|
||||||
|
idle share. Bench v2's per-phase accounting is what makes these comparable.
|
||||||
|
|
||||||
|
```
|
||||||
|
iris bench report
|
||||||
|
frames=692 janky%=32.37 p50=6.2ms p90=32.0ms p99=42.1ms worst=52.6ms (measures redraw-start to after present() is called, not GPU/compositor completion) cpu_p50=4.7ms gpu_wait_p50=1.3ms (redraw-start-to-submit vs. submit-to-after-present)
|
||||||
|
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||||||
|
process CPU time over this run: 15554ms
|
||||||
|
peak RSS: 365328kB
|
||||||
|
battery current: mean -563493µA over 22 samples (min -1807812, max -132812)
|
||||||
|
```
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# iris bench v2 report from Iris's phone, 2026-09-06
|
||||||
|
|
||||||
|
Build 2e3f4ad (bench v2, fling physics, keyboard-wipe fix, dp unit), run
|
||||||
|
by Iris on her Pixel 9 Pro XL, verbatim. The display was at **120 Hz**
|
||||||
|
(8.3 ms budget) where `compose-phone-v2-2026-09-06.md` ran at 60 Hz, so
|
||||||
|
compare the millisecond percentiles, not `late`.
|
||||||
|
|
||||||
|
Side by side (Compose 60 Hz / iris 120 Hz, p50 / p90 / p99 ms): fling
|
||||||
|
5.5/8.7/11.6 vs 3.8/6.9/12.6; stream 13.4/31.7/42.5 vs 18.2/35.8/43.1;
|
||||||
|
type 7.3/13.2/16.5 vs 7.2/9.2/11.2; keyboard: iris could not show the IME
|
||||||
|
(phase invalid). Process CPU 69.6 s over 125 s vs 40.6 s over 150 s; peak
|
||||||
|
RSS 577 MB vs 379 MB; battery current mean 571 mA vs 452 mA.
|
||||||
|
|
||||||
|
Iris's observations on the same run: "the scrolling is not similar at
|
||||||
|
all. It does not fling for me yet [with a finger], and the test also seems
|
||||||
|
to give it a constant velocity and abruptly stop it at some point. Also
|
||||||
|
unsure what's going on in that image with the compaction" -- her
|
||||||
|
screenshot shows the `Compacted: 180000 -> 20000 tokens.` row drawn twice
|
||||||
|
overlapping, and once more below the composer bar: primitives of a
|
||||||
|
replaced/removed row surviving in the GPU buffers, the same shape as the
|
||||||
|
header drawn twice after a keyboard resize.
|
||||||
|
|
||||||
|
```
|
||||||
|
iris bench report
|
||||||
|
per phase:
|
||||||
|
fling: 1783 frames over 53.2s
|
||||||
|
late: 104 (5.8%)
|
||||||
|
total p50 3.8ms p90 6.9ms p99 12.6ms
|
||||||
|
worst 29.1ms
|
||||||
|
stream: 401 frames over 21.3s
|
||||||
|
late: 306 (76.3%)
|
||||||
|
total p50 18.2ms p90 35.8ms p99 43.1ms
|
||||||
|
worst 43.8ms
|
||||||
|
type: 1202 frames over 65.7s
|
||||||
|
late: 309 (25.7%)
|
||||||
|
total p50 7.2ms p90 9.2ms p99 11.2ms
|
||||||
|
worst 15.3ms
|
||||||
|
keyboard: 9 frames over 9.7s
|
||||||
|
late: 9 (100.0%)
|
||||||
|
total p50 12.0ms p90 12.9ms p99 12.9ms
|
||||||
|
worst 12.9ms
|
||||||
|
|
||||||
|
frames:
|
||||||
|
3395 frames over 149.9s at 120Hz (8.3ms budget)
|
||||||
|
late: 728 (21.4%)
|
||||||
|
total p50 5.0ms p90 10.9ms p99 36.6ms
|
||||||
|
worst 43.8ms
|
||||||
|
cpu_p50 2.0ms gpu_wait_p50 2.6ms
|
||||||
|
|
||||||
|
bench:
|
||||||
|
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1217px outward=idx=651/off=101536px end=idx=651/off=1022px
|
||||||
|
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||||
|
type: 600 characters inserted then deleted, one per 50ms
|
||||||
|
keyboard: could not be shown (5 attempts, 0 confirmed visible)
|
||||||
|
process CPU time over this run: 40603ms
|
||||||
|
peak RSS: 379156kB
|
||||||
|
battery current: mean -452353µA over 149 samples (min -1753125, max -204687)
|
||||||
|
```
|
||||||
Generated
+107
@@ -0,0 +1,107 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-model"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "event-model"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
# The common event model, extracted from `server/session/driver.rs` and
|
||||||
|
# `session/transcript.rs` so a Rust client (`client-core`) can share one
|
||||||
|
# definition with the server instead of hand-mirroring it the way
|
||||||
|
# `app/.../Events.kt` used to. Nothing here talks to a process, a file, or a
|
||||||
|
# socket -- it is exactly the wire shape in PLAN.md's "common event model",
|
||||||
|
# plus the transcript envelope and the context-token rule three different
|
||||||
|
# readers (the pump, the transcript, and a phone folding the same events)
|
||||||
|
# have to agree on.
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
# `Event::ToolStart.input` is a tool's raw call arguments, whatever shape the
|
||||||
|
# dialect gave them -- typing it further would mean this crate knowing every
|
||||||
|
# driver's tool schema.
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
//! The common event model: what a driver's process turns into before it
|
||||||
|
//! touches the transcript or the phone (see `PLAN.md`'s "The common event
|
||||||
|
//! model"). Extracted from `server/src/session/driver.rs` and
|
||||||
|
//! `session/transcript.rs` on 2026-09-04 so `client-core` shares this
|
||||||
|
//! definition instead of hand-mirroring it, which is what
|
||||||
|
//! `app/.../Events.kt` used to do. `server/`'s `session::driver` module
|
||||||
|
//! re-exports everything here, so nothing downstream of it had to change.
|
||||||
|
//!
|
||||||
|
//! What stayed behind in `server/`: the `Driver` trait, `SessionCommand`,
|
||||||
|
//! `Unqueued` and `EventSink`. Those are how *this* server runs a session,
|
||||||
|
//! not part of what a client reads off the wire.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The name a session's image is stored and served under -- minted for an
|
||||||
|
/// upload or for one a tool produced, and fetched back from
|
||||||
|
/// `/sessions/{id}/files/{ref}`. One id both directions, so the transcript
|
||||||
|
/// renders them identically.
|
||||||
|
pub type ImageRef = String;
|
||||||
|
|
||||||
|
/// The name an upload is stored and served under: an image is
|
||||||
|
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
|
||||||
|
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what
|
||||||
|
/// the reader attached and what the session is told. Told apart by
|
||||||
|
/// `crate::media::media_type_for`.
|
||||||
|
pub type AttachmentRef = String;
|
||||||
|
|
||||||
|
/// One choice offered in answer to a [`Event::Question`]. More than a label
|
||||||
|
/// because the reader is deciding rather than confirming: what an option
|
||||||
|
/// means, and what picking it would produce, are what decide it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct QuestionOption {
|
||||||
|
pub label: String,
|
||||||
|
/// A sentence about what this option means.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// A block to show as written -- a mockup, a diff, a config file.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub preview: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuestionOption {
|
||||||
|
pub fn plain(label: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a session can tell the outside world. Every event is appended
|
||||||
|
/// to the transcript with a sequence number, then fanned out to SSE
|
||||||
|
/// subscribers, so reconnecting is just "events after seq N" -- no separate
|
||||||
|
/// history path to drift from the live one.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
// `rename_all` renames the variants; `rename_all_fields` renames what is
|
||||||
|
// inside them. Both are needed and only the first is obvious: every field
|
||||||
|
// here was one lowercase word until `pre_tokens` arrived, so a multi-word
|
||||||
|
// field went out as snake_case, the app looked for camelCase and found
|
||||||
|
// nothing, and the event still rendered -- as the "no counts reported" case,
|
||||||
|
// which is a state it is allowed to be in.
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum Event {
|
||||||
|
/// What the user sent, written into the transcript by the manager (not by
|
||||||
|
/// drivers) so every device renders the conversation from one stream.
|
||||||
|
/// Recorded when the session reads it, which is what `MessageTaken` reports.
|
||||||
|
UserMessage {
|
||||||
|
/// The [`Event::MessageQueued`] this resolves, when it waited. The
|
||||||
|
/// phone has a bubble on screen for the waiting message and needs to
|
||||||
|
/// know *which* one this is, rather than matching on the text and
|
||||||
|
/// clearing the wrong one when the same thing was sent twice.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
id: Option<String>,
|
||||||
|
text: String,
|
||||||
|
/// What was attached, by the ref the files route serves. On the
|
||||||
|
/// message rather than beside it: these used to be their own `Image`
|
||||||
|
/// events just before, which left the phone deciding from adjacency
|
||||||
|
/// which message an image belonged to. `images` on disk until
|
||||||
|
/// 2026-09-03, when files joined them; the alias reads the older rows.
|
||||||
|
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||||
|
attachments: Vec<AttachmentRef>,
|
||||||
|
},
|
||||||
|
/// A message accepted from the phone that the session cannot read yet.
|
||||||
|
///
|
||||||
|
/// Recorded, unlike the message itself, and that difference is the point:
|
||||||
|
/// the message belongs in the transcript where the session read it, but
|
||||||
|
/// something has to say it is waiting, and it has to be the server. The
|
||||||
|
/// phone used to remember its own outgoing messages, so leaving the
|
||||||
|
/// screen showed nothing pending when something was.
|
||||||
|
///
|
||||||
|
/// Carries no row of its own; resolved by the `UserMessage` bearing the
|
||||||
|
/// same id, as `CommandQueued` is resolved by `CommandSent`.
|
||||||
|
MessageQueued {
|
||||||
|
id: String,
|
||||||
|
text: String,
|
||||||
|
/// Carried for the same reason [`Event::UserMessage`] carries it,
|
||||||
|
/// and it matters more here: a waiting message is on screen for as
|
||||||
|
/// long as the turn runs, so its attachment has nowhere else to be.
|
||||||
|
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||||
|
attachments: Vec<AttachmentRef>,
|
||||||
|
},
|
||||||
|
/// A message taken out of the queue before the session read it.
|
||||||
|
///
|
||||||
|
/// Recorded for the same reason `MessageQueued` is: the queue is the
|
||||||
|
/// server's, so what is waiting has to be answerable from the transcript
|
||||||
|
/// alone. Without it a phone that reconnects replays the `MessageQueued`
|
||||||
|
/// and puts back a bubble nothing will ever resolve -- the `UserMessage`
|
||||||
|
/// that normally does is exactly what is not coming.
|
||||||
|
///
|
||||||
|
/// Only ever sent for a message that had not been handed over; see
|
||||||
|
/// [`Unqueued::AlreadySent`].
|
||||||
|
MessageDropped {
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
/// A driver has taken one of the user's messages and started reading it.
|
||||||
|
/// The manager turns this into the `UserMessage` above, so it never
|
||||||
|
/// reaches a phone itself.
|
||||||
|
///
|
||||||
|
/// It exists because sending and being read are not the same moment. A
|
||||||
|
/// message sent into a running turn waits, and recording it among things
|
||||||
|
/// already read puts it in the transcript above output that predates it.
|
||||||
|
MessageTaken {
|
||||||
|
/// The `MessageQueued` this answers, or `None` when it never waited.
|
||||||
|
/// Carried through onto the `UserMessage`.
|
||||||
|
id: Option<String>,
|
||||||
|
text: String,
|
||||||
|
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||||
|
attachments: Vec<AttachmentRef>,
|
||||||
|
},
|
||||||
|
/// Streaming assistant text; the phone renders the concatenation as
|
||||||
|
/// markdown.
|
||||||
|
AssistantText {
|
||||||
|
delta: String,
|
||||||
|
},
|
||||||
|
ToolStart {
|
||||||
|
id: String,
|
||||||
|
tool: String,
|
||||||
|
input: serde_json::Value,
|
||||||
|
},
|
||||||
|
ToolUpdate {
|
||||||
|
id: String,
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
ToolEnd {
|
||||||
|
id: String,
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
/// An image the session produced or was sent, saved under the session
|
||||||
|
/// dir and referenced by id; the phone fetches it by URL.
|
||||||
|
Image {
|
||||||
|
#[serde(rename = "ref")]
|
||||||
|
image: ImageRef,
|
||||||
|
/// The tool call whose result carried it, when one did. A screenshot
|
||||||
|
/// belongs under the call that took it, not floating beside it -- the
|
||||||
|
/// reader has to pair them by position otherwise, and position is
|
||||||
|
/// exactly what a page boundary breaks.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
about: Option<String>,
|
||||||
|
},
|
||||||
|
/// Anything the session needs a human for: AskUserQuestion, and
|
||||||
|
/// permission requests, are the same shape with different options.
|
||||||
|
Question {
|
||||||
|
id: String,
|
||||||
|
prompt: String,
|
||||||
|
/// A few words naming what the question is about, when the asker
|
||||||
|
/// offered one. `None` for a permission, which is about the call
|
||||||
|
/// above it.
|
||||||
|
header: Option<String>,
|
||||||
|
options: Vec<QuestionOption>,
|
||||||
|
/// Whether several options may be chosen at once. Here rather than
|
||||||
|
/// left for a phone to work out from the dialect underneath: how many
|
||||||
|
/// answers a question takes is a fact about the question, and the
|
||||||
|
/// alternative was Claude Code's tool-input schema written out a
|
||||||
|
/// second time in Kotlin, where no other dialect could reach it.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
multi_select: bool,
|
||||||
|
/// The tool call this is permission for, when it is one, so a phone
|
||||||
|
/// can draw the ask on the tool's own row rather than as a second
|
||||||
|
/// card repeating its input. `None` for anything not about a tool.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
about: Option<String>,
|
||||||
|
},
|
||||||
|
/// A message another agent sent this session.
|
||||||
|
///
|
||||||
|
/// Its own kind rather than a `UserMessage`, because it is not something
|
||||||
|
/// the reader said and a transcript that renders it in their voice is
|
||||||
|
/// claiming they did. It also explains what would otherwise be
|
||||||
|
/// inexplicable: a session working on something nobody here asked for.
|
||||||
|
PeerMessage {
|
||||||
|
/// The sending session's own name, which is what the reader
|
||||||
|
/// recognises it by -- the socket path it came from is not.
|
||||||
|
from: String,
|
||||||
|
text: String,
|
||||||
|
/// The seq of the `Status::Running` that opened the turn this message
|
||||||
|
/// started, so a reader can draw it above that turn.
|
||||||
|
///
|
||||||
|
/// The CLI says nothing about a peer message until the turn's
|
||||||
|
/// `result`, so the event is appended after everything it caused, and
|
||||||
|
/// an append-only transcript cannot go back and insert it. Carrying
|
||||||
|
/// the position instead keeps one order on the wire and one on screen.
|
||||||
|
///
|
||||||
|
/// Filled in by the pump, the only place that knows a seq, and only
|
||||||
|
/// where a turn was open: `None` for a message replayed by `import`,
|
||||||
|
/// which already has it in the right place.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
turn_start: Option<u64>,
|
||||||
|
},
|
||||||
|
/// The manager's record of a question being answered, so a rendered
|
||||||
|
/// question card resolves on every device rather than only the one that
|
||||||
|
/// answered.
|
||||||
|
///
|
||||||
|
/// A list because a question can take several answers, and one that took
|
||||||
|
/// one is the list of length one rather than a different shape.
|
||||||
|
Answered {
|
||||||
|
id: String,
|
||||||
|
answers: Vec<String>,
|
||||||
|
},
|
||||||
|
Status {
|
||||||
|
state: SessionStatus,
|
||||||
|
},
|
||||||
|
/// What the session is set to, as the session itself reports it.
|
||||||
|
///
|
||||||
|
/// Asking for a change and having one are different things, and only this
|
||||||
|
/// is a measurement: a model name the dialect does not know, a mode it
|
||||||
|
/// refuses, or a driver whose model is fixed at startup all leave a
|
||||||
|
/// request that was sent and nothing that changed. Reporting from the
|
||||||
|
/// request put the answer on the phone before the question was answered.
|
||||||
|
///
|
||||||
|
/// Either field alone, because the two are confirmed separately.
|
||||||
|
Settings {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
model: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
permission_mode: Option<String>,
|
||||||
|
},
|
||||||
|
/// Per-turn token counts, where the dialect reports them.
|
||||||
|
UsageDelta {
|
||||||
|
/// What this turn cost: the tokens it was charged for.
|
||||||
|
tokens: u64,
|
||||||
|
/// What the model was holding when the turn ended -- see
|
||||||
|
/// [`context_tokens`].
|
||||||
|
///
|
||||||
|
/// Carried rather than summed by whoever is reading, because it is
|
||||||
|
/// not a sum: context goes *down* at a compaction and a clear, so
|
||||||
|
/// adding turns up would report a figure the session stopped being
|
||||||
|
/// true of long ago.
|
||||||
|
///
|
||||||
|
/// `None` where the dialect did not say, which every reader has to be
|
||||||
|
/// able to draw.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
context: Option<u64>,
|
||||||
|
},
|
||||||
|
/// A compaction that finished, and how much context it recovered.
|
||||||
|
///
|
||||||
|
/// The counts are the point, and a spinner is not. They are optional
|
||||||
|
/// because the record has shipped without them, and "the compaction
|
||||||
|
/// happened, we don't know by how much" is a state this has to be able to
|
||||||
|
/// say -- a plausible number would be indistinguishable from a counted one.
|
||||||
|
Compacted {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pre_tokens: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
post_tokens: Option<u64>,
|
||||||
|
/// What asked for it, in the dialect's own word -- `auto` when the
|
||||||
|
/// session compacted on its own. Carried rather than reduced to a bool
|
||||||
|
/// so an unrecognised trigger stays unrecognised: an automatic
|
||||||
|
/// compaction is the one worth naming, because it explains a wait
|
||||||
|
/// nobody asked for.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
trigger: Option<String>,
|
||||||
|
},
|
||||||
|
/// A command the session was asked to run on itself, held because it
|
||||||
|
/// cannot run yet. These are not messages: `/compact` and `/rename` are
|
||||||
|
/// instructions about the session, and a session mid-turn reads a line
|
||||||
|
/// written to it as something the model should see. So they wait, and
|
||||||
|
/// this is what a phone draws while they do.
|
||||||
|
CommandQueued {
|
||||||
|
id: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// The same command, now handed to the session. Its [`CommandQueued`]
|
||||||
|
/// stops being pending when this arrives, matched by `id`; a command
|
||||||
|
/// that ran immediately has only this.
|
||||||
|
CommandSent {
|
||||||
|
id: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// The conversation was cleared: everything above this is still in the
|
||||||
|
/// record but is no longer in the session's context.
|
||||||
|
///
|
||||||
|
/// Nothing is deleted. A transcript is the thing a person scrolls back
|
||||||
|
/// through, so this is a divider, not a truncation.
|
||||||
|
///
|
||||||
|
/// **Load-bearing, not decorative.** For any driver that rebuilds its
|
||||||
|
/// conversation from the transcript, this marker decides what the model
|
||||||
|
/// is given -- dropping it, or treating it as something only the phone
|
||||||
|
/// draws, silently puts a cleared conversation back in front of the model
|
||||||
|
/// at full cost. Today `llama::conversation` is the only fold that reads
|
||||||
|
/// it, which is why this is written down rather than left to be inferred
|
||||||
|
/// from a second example that does not exist.
|
||||||
|
Cleared,
|
||||||
|
/// The account behind this session has no quota left, so the turn stopped
|
||||||
|
/// without finishing.
|
||||||
|
///
|
||||||
|
/// Its own event rather than an [`Event::Error`] carrying the dialect's
|
||||||
|
/// sentence, because two things act on it that cannot read English: the
|
||||||
|
/// transcript draws it as a state the session is in rather than as a
|
||||||
|
/// failure of something it did, and `crate::resume` schedules the message
|
||||||
|
/// that picks the work back up. Recognising it belongs to the driver, which
|
||||||
|
/// is the only layer that knows its dialect's wording -- above here nothing
|
||||||
|
/// matches on strings.
|
||||||
|
///
|
||||||
|
/// `resets_at` is epoch seconds, and `None` is a real state: the dialect
|
||||||
|
/// said the limit was hit without saying when it lifts. Nothing here
|
||||||
|
/// invents one -- what the wait is actually decided against is the usage
|
||||||
|
/// endpoint, and this is the hint that starts the waiting.
|
||||||
|
LimitReached {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
resets_at: Option<f64>,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much the model was holding, from the three figures a turn reports:
|
||||||
|
/// the input side only, prompt plus both cache figures. A cached token is
|
||||||
|
/// cheaper but it is still one the model was given; output is what the turn
|
||||||
|
/// produced rather than what continuing has to carry.
|
||||||
|
///
|
||||||
|
/// One function so the definition cannot drift, because it is extracted two
|
||||||
|
/// quite different ways -- the live translators have the usage object parsed,
|
||||||
|
/// and `import::context_tokens` scans it out of a raw line without parsing.
|
||||||
|
pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
|
||||||
|
input + cache_creation + cache_read
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The context after `event`, given what it was before.
|
||||||
|
///
|
||||||
|
/// The whole rule in one place, because three readers need the same answer:
|
||||||
|
/// the pump keeping a live session's figure, the transcript seeding it at
|
||||||
|
/// startup, and the phone folding the same events into what it draws.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// `None` is "we don't know", which each of them can reach.
|
||||||
|
pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
|
||||||
|
match event {
|
||||||
|
// `or`, so a turn the dialect reported no usage for leaves the last
|
||||||
|
// measurement standing: stale by a turn, which every context figure
|
||||||
|
// is, rather than wrong.
|
||||||
|
Event::UsageDelta { context, .. } => context.or(current),
|
||||||
|
Event::Compacted { post_tokens, .. } => *post_tokens,
|
||||||
|
Event::Cleared => None,
|
||||||
|
_ => current,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum SessionStatus {
|
||||||
|
Idle,
|
||||||
|
Running,
|
||||||
|
AwaitingInput,
|
||||||
|
Compacting,
|
||||||
|
Exited,
|
||||||
|
/// There is a process recorded for this session and the machine will not
|
||||||
|
/// say whether it is still running.
|
||||||
|
///
|
||||||
|
/// Its own state rather than the nearest of the others, because both
|
||||||
|
/// neighbours are lies with consequences: `Exited` invites starting a
|
||||||
|
/// second process against a conversation that may already have one, and
|
||||||
|
/// `Idle` claims a session is waiting for you when nobody has checked.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One transcript line: an [`Event`] plus its position and time. The event
|
||||||
|
/// is flattened so the wire shape stays one flat object.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SeqEvent {
|
||||||
|
pub seq: u64,
|
||||||
|
/// Epoch seconds.
|
||||||
|
pub ts: f64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub event: Event,
|
||||||
|
}
|
||||||
Generated
+1557
-166
File diff suppressed because it is too large.
Load diff
+82
-7
@@ -8,20 +8,89 @@ edition.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
iris-core = { workspace = true }
|
iris-core = { workspace = true }
|
||||||
iris-macro = { workspace = true }
|
iris-macro = { workspace = true }
|
||||||
cosmic-text = { workspace = true }
|
parley = { workspace = true }
|
||||||
unicode-segmentation = { workspace = true }
|
swash = { workspace = true }
|
||||||
winit = { workspace = true }
|
|
||||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
|
||||||
pollster = { workspace = true }
|
pollster = { workspace = true }
|
||||||
wgpu = { workspace = true }
|
wgpu = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
accesskit = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
||||||
|
|
||||||
|
# winit everywhere except Android; android-view (below) is what stands in
|
||||||
|
# for it there. Both backends live in this crate (see `src/android/mod.rs`'s
|
||||||
|
# doc comment) but are never compiled together: winit's own Android support
|
||||||
|
# pulls in `android-activity`, which panics at compile time unless one of
|
||||||
|
# its own backend features is picked, and picking one is exactly what
|
||||||
|
# `iris-core` was kept free of (RUST.md's I0b). Confirmed by trying it
|
||||||
|
# 2026-09-05: `cargo ndk -t x86_64 -P 26 build -p iris` failed inside
|
||||||
|
# `android-activity` itself with "Either game-activity or native-activity
|
||||||
|
# must be enabled" before this split existed.
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
|
winit = { workspace = true }
|
||||||
|
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||||
|
# I4 (RUST.md): the desktop half of the AccessKit push, `winit`'s own
|
||||||
|
# adapter over `accesskit`. No pin needed the way android-view's rev is
|
||||||
|
# pinned -- this is an ordinary crates.io release with no local abort to
|
||||||
|
# track (that finding is Android-only, see below).
|
||||||
|
accesskit_winit = "0.34.0"
|
||||||
|
|
||||||
|
# Pinned to the exact commit RUST.md's E1 (2026-09-04) measured on this
|
||||||
|
# emulator -- real Vulkan rendering, a working `InputConnection`, and the
|
||||||
|
# accesskit-detach abort, all against this rev specifically. Advancing it
|
||||||
|
# wants re-running E1's checks, the same reason the nightly toolchain pin
|
||||||
|
# is dated rather than floating.
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||||
|
# I4 (RUST.md): the Android half of the AccessKit push, over android-view's
|
||||||
|
# `AccessibilityNodeProvider`. **0.8.0 carries the same detach-abort E1
|
||||||
|
# found on 0.4.0** (the `State` enum still never returns to `Inactive`,
|
||||||
|
# and `send_completed_event` still unwraps a Java exception) -- advancing
|
||||||
|
# the version is not the fix, so pinning to a specific rev buys nothing
|
||||||
|
# here the way it does for android-view itself. `android/view.rs`'s
|
||||||
|
# `raise_if_enabled` is the mitigation, carried from E1.
|
||||||
|
accesskit_android = "0.8.0"
|
||||||
|
# Not re-exported by android-view (only `jni` and `ndk` are), and needed
|
||||||
|
# for `android/insets.rs`'s own id -> state map -- the same reason
|
||||||
|
# android-view's own `PEER_MAP` carries one.
|
||||||
|
send_wrapper = "0.6.0"
|
||||||
|
# For diagnostics visible through android_logger, wherever the app crate
|
||||||
|
# installs it -- this crate never installs a logger itself.
|
||||||
|
log = "0.4.28"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# RUST.md's I5 "Where iris's frame time goes" diagnosis: forces the Android
|
||||||
|
# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so the
|
||||||
|
# same build can be measured against SwiftShader's software Vulkan ICD (the
|
||||||
|
# default) or virgl's GLES path, without a second env-var plumbing path that
|
||||||
|
# nothing on this machine can hand to an already-launched Android process
|
||||||
|
# (there is no `am start` environment and no system-property reader here to
|
||||||
|
# add one). Android-only; `android/render.rs` is the only reader.
|
||||||
|
force-gles = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
||||||
|
# The tabs example's widget tree. A dev-dependency cycle back to this
|
||||||
|
# package is fine -- cargo excludes dev-dependencies from the graph used
|
||||||
|
# to build the library itself, so this only matters for `--examples`.
|
||||||
|
tabs-ui = { path = "tabs-ui" }
|
||||||
|
|
||||||
|
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
|
||||||
|
# header for why. `harness = false` opts out of the unstable `#[bench]`
|
||||||
|
# test-crate harness cargo would otherwise want, in favour of an ordinary
|
||||||
|
# `fn main()`.
|
||||||
|
[[bench]]
|
||||||
|
name = "message_list"
|
||||||
|
harness = false
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["core", "macro"]
|
members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"]
|
||||||
|
# android-app pulls in android-view, which needs the NDK sysroot to link
|
||||||
|
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
||||||
|
# buildable. Cross-compile it from its own directory (its own single-crate
|
||||||
|
# workspace, since it has no `[workspace]` table of its own and this
|
||||||
|
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
|
||||||
|
# -t x86_64 -P 26 build`.
|
||||||
|
exclude = ["android-app"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -33,10 +102,16 @@ winit = "0.30.12"
|
|||||||
wgpu = "28.0.0"
|
wgpu = "28.0.0"
|
||||||
bytemuck = "1.23.1"
|
bytemuck = "1.23.1"
|
||||||
image = "0.25.6"
|
image = "0.25.6"
|
||||||
cosmic-text = "0.16.0"
|
parley = "0.11.1"
|
||||||
unicode-segmentation = "1.12.0"
|
swash = "0.2.10"
|
||||||
fxhash = "0.2.1"
|
fxhash = "0.2.1"
|
||||||
arboard = "3.6.1"
|
arboard = "3.6.1"
|
||||||
|
accesskit = "0.25.0"
|
||||||
iris-core = { path = "core" }
|
iris-core = { path = "core" }
|
||||||
iris-macro = { path = "macro" }
|
iris-macro = { path = "macro" }
|
||||||
tokio = "1.49.0"
|
tokio = "1.49.0"
|
||||||
|
# Current stable as of 2026-09-05 (`cargo search`) -- I5's markdown block
|
||||||
|
# model, the same crate E2's uncommitted `e2-transcript` experiment used for
|
||||||
|
# the identical job (RUST.md), rather than reimplementing a CommonMark
|
||||||
|
# parser.
|
||||||
|
pulldown-cmark = "0.13.4"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
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.
|
||||||
|
app/src/main/jniLibs/
|
||||||
Generated
+5222
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,74 @@
|
|||||||
|
[package]
|
||||||
|
name = "iris-android-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
# Deliberately outside the `iris` workspace (see that Cargo.toml's
|
||||||
|
# `[workspace] exclude`): this crate exists only to be cross-compiled with
|
||||||
|
# `cargo ndk` for the emulator/a phone, and pulls in android-view, which
|
||||||
|
# needs the NDK sysroot to link. Folding it into the main workspace would
|
||||||
|
# make `cargo build --workspace --all-targets` -- the host command RUST.md
|
||||||
|
# and AGENTS.md both require to stay clean -- try to link a cdylib against
|
||||||
|
# libraries that do not exist on this machine. See RUST.md's I2.
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "main"
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iris = { path = "../" }
|
||||||
|
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||||
|
android_logger = "0.15.0"
|
||||||
|
log = "0.4.28"
|
||||||
|
# `tabs-screen` (default, I2/I4's demo) and `transcript-screen` (I5's
|
||||||
|
# Android integration) are mutually exclusive -- one `ActiveClient` type is
|
||||||
|
# compiled in, never both (`lib.rs`'s doc comment) -- so both sets of deps
|
||||||
|
# are optional and each screen's feature pulls in only its own. Without
|
||||||
|
# this, building `--features transcript-screen` alone (default features
|
||||||
|
# still on) left `tabs-ui` linked but never referenced under that cfg,
|
||||||
|
# which Cargo's `unused_dependencies` lint (on by default) correctly flags.
|
||||||
|
tabs-ui = { path = "../tabs-ui", optional = true }
|
||||||
|
transcript-ui = { path = "../transcript-ui", optional = true }
|
||||||
|
client-core = { path = "../../client-core", optional = true }
|
||||||
|
event-model = { path = "../../event-model", optional = true }
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
|
||||||
|
# P0's bench build only (docs/RUST.md): `getrusage(RUSAGE_SELF)` for
|
||||||
|
# process CPU time, matching `libc::getrusage`'s mention in that box over
|
||||||
|
# parsing `/proc/self/stat` by hand and assuming `USER_HZ`. Already in the
|
||||||
|
# workspace's own dependency tree transitively (`iris/Cargo.lock`, pinned
|
||||||
|
# at 0.2.179) -- this makes it a direct dependency at the same version
|
||||||
|
# rather than a second, possibly-drifting resolution.
|
||||||
|
libc = { version = "0.2.179", optional = true }
|
||||||
|
# P0's bench build only: the scroll animation and the streaming phase are
|
||||||
|
# both a sequence of `sleep`s inside the async task `rsc.spawn_task` already
|
||||||
|
# runs on iris's own tokio runtime (`iris/src/task.rs`'s `Tasks::init`), and
|
||||||
|
# the battery sampler is a second, concurrent task on that same runtime
|
||||||
|
# (`tokio::spawn`) -- so this crate needs `tokio` directly rather than only
|
||||||
|
# through `iris`. `rt`+`time` only: no I/O, no macros, nothing this crate
|
||||||
|
# doesn't call. Version matches the one `iris`'s own dependency tree already
|
||||||
|
# resolves to (`iris/Cargo.lock`), so there is one copy of the runtime, not
|
||||||
|
# two.
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["tabs-screen"]
|
||||||
|
tabs-screen = ["dep:tabs-ui"]
|
||||||
|
transcript-screen = ["dep:transcript-ui", "dep:client-core", "dep:event-model", "dep:serde_json"]
|
||||||
|
# RUST.md's I5 "Where iris's frame time goes": forces the GLES backend
|
||||||
|
# instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc
|
||||||
|
# on the feature this forwards to.
|
||||||
|
force-gles = ["iris/force-gles"]
|
||||||
|
# P0's iris half (docs/RUST.md, docs/AGENTS.md's "The rigs"): the same
|
||||||
|
# checked-in fixture, scroll loop and streaming phase the Compose `bench`
|
||||||
|
# build type drives, run here against `transcript-ui`'s real screen with no
|
||||||
|
# server. Depends on `transcript-screen` for `transcript-ui`/`client-core`/
|
||||||
|
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
|
||||||
|
# priority over `transcript-screen`'s own `TranscriptClient` when both are
|
||||||
|
# listed, which is how this crate's build command names both explicitly.
|
||||||
|
bench = ["transcript-screen", "dep:libc", "dep:tokio"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
panic = "abort"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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>
|
||||||
|
|
||||||
|
<meta-data android:name="android.app.lib_name" android:value="main" />
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.ClipData;
|
||||||
|
import android.content.ClipboardManager;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.ScrollView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import org.linebender.android.rustview.RustView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* android-view's abstract base plus the two native methods it has no hook
|
||||||
|
* for: window insets and unregistering this view's entry in
|
||||||
|
* iris::android::insets's side table. See iris/src/android/insets.rs's doc
|
||||||
|
* comment for why those could not ride along on an existing android-view
|
||||||
|
* callback the way the back gesture does.
|
||||||
|
*/
|
||||||
|
public final class IrisView extends RustView {
|
||||||
|
@Override
|
||||||
|
protected native long newViewPeer(Context context);
|
||||||
|
|
||||||
|
native void applyWindowInsetsNative(
|
||||||
|
long peer, int left, int top, int right, int bottom, int imeBottom);
|
||||||
|
|
||||||
|
native void unregisterInsetsNative(long peer);
|
||||||
|
|
||||||
|
public IrisView(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyWindowInsets(int left, int top, int right, int bottom, int imeBottom) {
|
||||||
|
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
unregisterInsetsNative(mViewPeer);
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from the Rust side (iris/src/android/view.rs's
|
||||||
|
* `show_renderer_error`) when `AndroidRenderer::new` fails instead of
|
||||||
|
* drawing -- an ordinary instance method rather than a `native` one,
|
||||||
|
* since this call is Rust reaching into Java rather than the other
|
||||||
|
* direction. Replaces the whole activity content with plain,
|
||||||
|
* selectable, scrollable text rather than leaving the last frame (or a
|
||||||
|
* blank surface) on screen with no way to report what happened:
|
||||||
|
* UI_RULES.md's "a failure is reported where it happened, and says
|
||||||
|
* what to do next." No dialog and no styling beyond what is needed to
|
||||||
|
* read and copy the text -- this path exists for exactly the crash it
|
||||||
|
* replaces, so it must not depend on anything that could itself fail
|
||||||
|
* to render.
|
||||||
|
*/
|
||||||
|
void showRendererError(String report) {
|
||||||
|
Context context = getContext();
|
||||||
|
if (!(context instanceof Activity)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Activity activity = (Activity) context;
|
||||||
|
TextView text = new TextView(activity);
|
||||||
|
text.setText(report);
|
||||||
|
text.setTextIsSelectable(true);
|
||||||
|
text.setGravity(Gravity.TOP | Gravity.START);
|
||||||
|
int pad = (int) (16 * activity.getResources().getDisplayMetrics().density);
|
||||||
|
text.setPadding(pad, pad, pad, pad);
|
||||||
|
ScrollView scroll = new ScrollView(activity);
|
||||||
|
scroll.addView(text);
|
||||||
|
activity.setContentView(scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String DIAGNOSTICS_OVERLAY_TAG = "iris-diagnostics-overlay";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bench build's keyboard diagnostics capture
|
||||||
|
* (`bench_client.rs`'s `on_insets_changed` /
|
||||||
|
* `capture_keyboard_diagnostics`, via `bench_jni.rs`'s
|
||||||
|
* `PlatformHandle::show_diagnostics_overlay`): unlike
|
||||||
|
* `showRendererError` above, this adds a panel *over* this view
|
||||||
|
* (`MainActivity`'s `FrameLayout` still holds `IrisView` underneath,
|
||||||
|
* running) rather than replacing the activity's content, and gives it
|
||||||
|
* a Copy button and a Close that removes the panel -- so it draws
|
||||||
|
* (and can be read) whether or not iris itself is still putting
|
||||||
|
* anything on screen, without abandoning the session that produced
|
||||||
|
* it. Runs on the UI thread regardless of which thread calls it,
|
||||||
|
* since the call comes from a background task (a delayed capture
|
||||||
|
* after the keyboard opens), and touching the view tree off the UI
|
||||||
|
* thread is undefined.
|
||||||
|
*/
|
||||||
|
void showDiagnosticsOverlay(String report) {
|
||||||
|
Context context = getContext();
|
||||||
|
if (!(context instanceof Activity)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Activity activity = (Activity) context;
|
||||||
|
activity.runOnUiThread(() -> {
|
||||||
|
ViewGroup parent = (ViewGroup) getParent();
|
||||||
|
if (parent == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
View existing = parent.findViewWithTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||||
|
if (existing != null) {
|
||||||
|
parent.removeView(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
float density = activity.getResources().getDisplayMetrics().density;
|
||||||
|
int pad = (int) (16 * density);
|
||||||
|
|
||||||
|
LinearLayout overlay = new LinearLayout(activity);
|
||||||
|
overlay.setTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||||
|
overlay.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
overlay.setBackgroundColor(0xEE000000);
|
||||||
|
overlay.setPadding(pad, pad, pad, pad);
|
||||||
|
|
||||||
|
TextView text = new TextView(activity);
|
||||||
|
text.setText(report);
|
||||||
|
text.setTextIsSelectable(true);
|
||||||
|
text.setTextColor(0xFFFFFFFF);
|
||||||
|
ScrollView scroll = new ScrollView(activity);
|
||||||
|
scroll.addView(text);
|
||||||
|
overlay.addView(scroll, new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||||
|
|
||||||
|
LinearLayout buttonRow = new LinearLayout(activity);
|
||||||
|
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
buttonRow.setPadding(0, pad, 0, 0);
|
||||||
|
|
||||||
|
Button copy = new Button(activity);
|
||||||
|
copy.setText("Copy");
|
||||||
|
copy.setOnClickListener(v -> {
|
||||||
|
ClipboardManager clipboard =
|
||||||
|
(ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||||
|
if (clipboard != null) {
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("iris diagnostics", report));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Button close = new Button(activity);
|
||||||
|
close.setText("Close");
|
||||||
|
close.setOnClickListener(v -> parent.removeView(overlay));
|
||||||
|
buttonRow.addView(copy);
|
||||||
|
buttonRow.addView(close);
|
||||||
|
overlay.addView(buttonRow);
|
||||||
|
|
||||||
|
parent.addView(overlay, new FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.WindowInsets;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
||||||
|
* filling the window, running iris's tabs example through
|
||||||
|
* iris-android-app's Rust side. Mirrors android-view's own
|
||||||
|
* DemoActivity, plus the window-insets wiring that has no android-view
|
||||||
|
* counterpart.
|
||||||
|
*/
|
||||||
|
public final class MainActivity extends Activity {
|
||||||
|
static {
|
||||||
|
System.loadLibrary("main");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle state) {
|
||||||
|
super.onCreate(state);
|
||||||
|
IrisView view = new IrisView(this);
|
||||||
|
view.setLayoutParams(new FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||||
|
view.setFocusable(true);
|
||||||
|
view.setFocusableInTouchMode(true);
|
||||||
|
FrameLayout layout = new FrameLayout(this);
|
||||||
|
layout.addView(view);
|
||||||
|
setContentView(layout);
|
||||||
|
view.requestFocus();
|
||||||
|
|
||||||
|
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||||
|
int left = insets.getSystemWindowInsetLeft();
|
||||||
|
int top = insets.getSystemWindowInsetTop();
|
||||||
|
int right = insets.getSystemWindowInsetRight();
|
||||||
|
int bottom = insets.getSystemWindowInsetBottom();
|
||||||
|
// The manifest declares adjustResize (AGENTS.md: without it the
|
||||||
|
// keyboard pans the whole window instead of resizing it), and
|
||||||
|
// under adjustResize the window itself shrinks to make room for
|
||||||
|
// the keyboard -- which is exactly the condition under which
|
||||||
|
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it
|
||||||
|
// measures how much of the window the keyboard overlaps, and
|
||||||
|
// resize already made that overlap zero by construction. That
|
||||||
|
// numeric inset is not a usable "is the keyboard open" signal
|
||||||
|
// here (found while root-causing why bench_client.rs's keyboard
|
||||||
|
// phase and auto-diagnostics never fired on the emulator despite
|
||||||
|
// the keyboard visibly opening -- RUST.md's P0 box). What does
|
||||||
|
// survive adjustResize is the boolean isVisible() answer, set
|
||||||
|
// from the platform's own start/end of the transition over a
|
||||||
|
// different path than the inset amount -- the same fact
|
||||||
|
// AGENTS.md's "Things that have bitten" already names for the
|
||||||
|
// Compose side's identical trap. Passed through as a 0/1 stand-
|
||||||
|
// in for the ime_bottom pixel amount, since nothing on the Rust
|
||||||
|
// side reads it as a real pixel value -- only `> 0.0`.
|
||||||
|
int imeBottom = 0;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||||
|
&& insets.isVisible(WindowInsets.Type.ime())) {
|
||||||
|
imeBottom = 1;
|
||||||
|
}
|
||||||
|
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package org.linebender.android.rustview;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.view.KeyEvent;
|
||||||
|
import android.view.inputmethod.CompletionInfo;
|
||||||
|
import android.view.inputmethod.CorrectionInfo;
|
||||||
|
import android.view.inputmethod.ExtractedText;
|
||||||
|
import android.view.inputmethod.ExtractedTextRequest;
|
||||||
|
import android.view.inputmethod.InputConnection;
|
||||||
|
import android.view.inputmethod.InputContentInfo;
|
||||||
|
|
||||||
|
class RustInputConnection implements InputConnection {
|
||||||
|
private final RustView mView;
|
||||||
|
|
||||||
|
RustInputConnection(RustView view) {
|
||||||
|
mView = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getViewPeer() {
|
||||||
|
return mView.mViewPeer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharSequence getTextBeforeCursor(int n, int flags) {
|
||||||
|
return mView.getTextBeforeCursorNative(getViewPeer(), n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharSequence getTextAfterCursor(int n, int flags) {
|
||||||
|
return mView.getTextAfterCursorNative(getViewPeer(), n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharSequence getSelectedText(int flags) {
|
||||||
|
return mView.getSelectedTextNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getCursorCapsMode(int reqModes) {
|
||||||
|
return mView.getCursorCapsModeNative(getViewPeer(), reqModes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
|
||||||
|
return mView.deleteSurroundingTextNative(getViewPeer(), beforeLength, afterLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
|
||||||
|
return mView.deleteSurroundingTextInCodePointsNative(getViewPeer(), beforeLength, afterLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean setComposingText(CharSequence text, int newCursorPosition) {
|
||||||
|
return mView.setComposingTextNative(getViewPeer(), text.toString(), newCursorPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean setComposingRegion(int start, int end) {
|
||||||
|
return mView.setComposingRegionNative(getViewPeer(), start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean finishComposingText() {
|
||||||
|
return mView.finishComposingTextNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitText(CharSequence text, int newCursorPosition) {
|
||||||
|
return mView.commitTextNative(getViewPeer(), text.toString(), newCursorPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitCompletion(CompletionInfo text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitCorrection(CorrectionInfo correctionInfo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean setSelection(int start, int end) {
|
||||||
|
return mView.setSelectionNative(getViewPeer(), start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performEditorAction(int editorAction) {
|
||||||
|
return mView.performEditorActionNative(getViewPeer(), editorAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performContextMenuAction(int id) {
|
||||||
|
return mView.performContextMenuActionNative(getViewPeer(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean beginBatchEdit() {
|
||||||
|
return mView.beginBatchEditNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean endBatchEdit() {
|
||||||
|
return mView.endBatchEditNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean sendKeyEvent(KeyEvent event) {
|
||||||
|
return mView.inputConnectionSendKeyEventNative(getViewPeer(), event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean clearMetaKeyStates(int states) {
|
||||||
|
return mView.inputConnectionClearMetaKeyStatesNative(getViewPeer(), states);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean reportFullscreenMode(boolean enabled) {
|
||||||
|
return mView.inputConnectionReportFullscreenModeNative(getViewPeer(), enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performPrivateCommand(String action, Bundle data) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean requestCursorUpdates(int cursorUpdateMode) {
|
||||||
|
return mView.requestCursorUpdatesNative(getViewPeer(), cursorUpdateMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Handler getHandler() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void closeConnection() {
|
||||||
|
mView.closeInputConnectionNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package org.linebender.android.rustview;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Rect;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.Choreographer;
|
||||||
|
import android.view.KeyEvent;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.SurfaceHolder;
|
||||||
|
import android.view.SurfaceView;
|
||||||
|
import android.view.accessibility.AccessibilityNodeInfo;
|
||||||
|
import android.view.accessibility.AccessibilityNodeProvider;
|
||||||
|
import android.view.inputmethod.EditorInfo;
|
||||||
|
import android.view.inputmethod.InputConnection;
|
||||||
|
import android.view.inputmethod.InputMethodManager;
|
||||||
|
|
||||||
|
public abstract class RustView extends SurfaceView
|
||||||
|
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
||||||
|
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view)
|
||||||
|
// with one deliberate change: `protected` rather than package-private, so a
|
||||||
|
// subclass in a different package (dev.iris.android.demo.IrisView) can pass
|
||||||
|
// it to the window-insets native call android-view itself has no hook for --
|
||||||
|
// see iris/src/android/insets.rs's doc comment for why that call exists at
|
||||||
|
// all. No other line differs from upstream.
|
||||||
|
protected final long mViewPeer;
|
||||||
|
final InputMethodManager mInputMethodManager;
|
||||||
|
|
||||||
|
protected abstract long newViewPeer(Context context);
|
||||||
|
|
||||||
|
public RustView(Context context) {
|
||||||
|
super(context);
|
||||||
|
mViewPeer = newViewPeer(context);
|
||||||
|
getHolder().addCallback(this);
|
||||||
|
mInputMethodManager =
|
||||||
|
(InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native int[] onMeasureNative(long peer, int widthSpec, int heightSpec);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onMeasure(int widthSpec, int heightSpec) {
|
||||||
|
int[] result = onMeasureNative(mViewPeer, widthSpec, heightSpec);
|
||||||
|
if (result != null) {
|
||||||
|
setMeasuredDimension(result[0], result[1]);
|
||||||
|
} else {
|
||||||
|
super.onMeasure(widthSpec, heightSpec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onLayoutNative(
|
||||||
|
long peer, boolean changed, int left, int top, int right, int bottom);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
|
||||||
|
onLayoutNative(mViewPeer, changed, left, top, right, bottom);
|
||||||
|
super.onLayout(changed, left, top, right, bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onSizeChangedNative(long peer, int w, int h, int oldw, int oldh);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||||
|
onSizeChangedNative(mViewPeer, w, h, oldw, oldh);
|
||||||
|
super.onSizeChanged(w, h, oldw, oldh);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onKeyDownNative(long peer, int keyCode, KeyEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||||
|
return onKeyDownNative(mViewPeer, keyCode, event) || super.onKeyDown(keyCode, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onKeyUpNative(long peer, int keyCode, KeyEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onKeyUp(int keyCode, KeyEvent event) {
|
||||||
|
return onKeyUpNative(mViewPeer, keyCode, event) || super.onKeyUp(keyCode, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onTrackballEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTrackballEvent(MotionEvent event) {
|
||||||
|
return onTrackballEventNative(mViewPeer, event) || super.onTrackballEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onTouchEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTouchEvent(MotionEvent event) {
|
||||||
|
return onTouchEventNative(mViewPeer, event) || super.onTouchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onGenericMotionEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onGenericMotionEvent(MotionEvent event) {
|
||||||
|
return onGenericMotionEventNative(mViewPeer, event) || super.onGenericMotionEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onHoverEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onHoverEvent(MotionEvent event) {
|
||||||
|
return onHoverEventNative(mViewPeer, event) || super.onHoverEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onFocusChangedNative(
|
||||||
|
long peer, boolean gainFocus, int direction, Rect previouslyFocusedRect);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
|
||||||
|
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
|
||||||
|
onFocusChangedNative(mViewPeer, gainFocus, direction, previouslyFocusedRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onWindowFocusChangedNative(long peer, boolean hasWindowFocus);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onWindowFocusChanged(boolean hasWindowFocus) {
|
||||||
|
super.onWindowFocusChanged(hasWindowFocus);
|
||||||
|
onWindowFocusChangedNative(mViewPeer, hasWindowFocus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onAttachedToWindowNative(long peer);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow();
|
||||||
|
onAttachedToWindowNative(mViewPeer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onDetachedFromWindowNative(long peer);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
onDetachedFromWindowNative(mViewPeer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onWindowVisibilityChangedNative(long peer, int visibility);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onWindowVisibilityChanged(int visibility) {
|
||||||
|
super.onWindowVisibilityChanged(visibility);
|
||||||
|
onWindowVisibilityChangedNative(mViewPeer, visibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void surfaceCreatedNative(long peer, SurfaceHolder holder);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceCreated(SurfaceHolder holder) {
|
||||||
|
surfaceCreatedNative(mViewPeer, holder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void surfaceChangedNative(
|
||||||
|
long peer, SurfaceHolder holder, int format, int width, int height);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
||||||
|
surfaceChangedNative(mViewPeer, holder, format, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void surfaceDestroyedNative(long peer, SurfaceHolder holder);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceDestroyed(SurfaceHolder holder) {
|
||||||
|
surfaceDestroyedNative(mViewPeer, holder);
|
||||||
|
}
|
||||||
|
|
||||||
|
void postFrameCallback() {
|
||||||
|
Choreographer c = Choreographer.getInstance();
|
||||||
|
c.removeFrameCallback(this);
|
||||||
|
c.postFrameCallback(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeFrameCallback() {
|
||||||
|
Choreographer.getInstance().removeFrameCallback(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void doFrameNative(long peer, long frameTimeNanos);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doFrame(long frameTimeNanos) {
|
||||||
|
doFrameNative(mViewPeer, frameTimeNanos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void delayedCallbackNative(long peer);
|
||||||
|
|
||||||
|
private final Runnable mDelayedCallback =
|
||||||
|
new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
delayedCallbackNative(mViewPeer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
boolean postDelayed(long delayMillis) {
|
||||||
|
return postDelayed(mDelayedCallback, delayMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean removeDelayedCallbacks() {
|
||||||
|
return removeCallbacks(mDelayedCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean hasAccessibilityNodeProviderNative(long peer);
|
||||||
|
|
||||||
|
private native AccessibilityNodeInfo createAccessibilityNodeInfoNative(
|
||||||
|
long peer, int virtualViewId);
|
||||||
|
|
||||||
|
private native AccessibilityNodeInfo accessibilityFindFocusNative(long peer, int virtualViewId);
|
||||||
|
|
||||||
|
private native boolean performAccessibilityActionNative(
|
||||||
|
long peer, int virtualViewId, int action, Bundle arguments);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccessibilityNodeProvider getAccessibilityNodeProvider() {
|
||||||
|
if (!hasAccessibilityNodeProviderNative(mViewPeer)) {
|
||||||
|
return super.getAccessibilityNodeProvider();
|
||||||
|
}
|
||||||
|
return new AccessibilityNodeProvider() {
|
||||||
|
@Override
|
||||||
|
public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
|
||||||
|
return createAccessibilityNodeInfoNative(mViewPeer, virtualViewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccessibilityNodeInfo findFocus(int focusType) {
|
||||||
|
return accessibilityFindFocusNative(mViewPeer, focusType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performAction(int virtualViewId, int action, Bundle arguments) {
|
||||||
|
return performAccessibilityActionNative(
|
||||||
|
mViewPeer, virtualViewId, action, arguments);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onCreateInputConnectionNative(long peer, EditorInfo outAttrs);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
|
||||||
|
if (!onCreateInputConnectionNative(mViewPeer, outAttrs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new RustInputConnection(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
native String getTextBeforeCursorNative(long peer, int n);
|
||||||
|
|
||||||
|
native String getTextAfterCursorNative(long peer, int n);
|
||||||
|
|
||||||
|
native String getSelectedTextNative(long peer);
|
||||||
|
|
||||||
|
native int getCursorCapsModeNative(long peer, int reqModes);
|
||||||
|
|
||||||
|
native boolean deleteSurroundingTextNative(long peer, int beforeLength, int afterLength);
|
||||||
|
|
||||||
|
native boolean deleteSurroundingTextInCodePointsNative(
|
||||||
|
long peer, int beforeLength, int afterLength);
|
||||||
|
|
||||||
|
native boolean setComposingTextNative(long peer, String text, int newCursorPosition);
|
||||||
|
|
||||||
|
native boolean setComposingRegionNative(long peer, int start, int end);
|
||||||
|
|
||||||
|
native boolean finishComposingTextNative(long peer);
|
||||||
|
|
||||||
|
native boolean commitTextNative(long peer, String text, int newCursorPosition);
|
||||||
|
|
||||||
|
native boolean setSelectionNative(long peer, int start, int end);
|
||||||
|
|
||||||
|
native boolean performEditorActionNative(long peer, int editorAction);
|
||||||
|
|
||||||
|
native boolean performContextMenuActionNative(long peer, int id);
|
||||||
|
|
||||||
|
native boolean beginBatchEditNative(long peer);
|
||||||
|
|
||||||
|
native boolean endBatchEditNative(long peer);
|
||||||
|
|
||||||
|
native boolean inputConnectionSendKeyEventNative(long peer, KeyEvent event);
|
||||||
|
|
||||||
|
native boolean inputConnectionClearMetaKeyStatesNative(long peer, int states);
|
||||||
|
|
||||||
|
native boolean inputConnectionReportFullscreenModeNative(long peer, boolean enabled);
|
||||||
|
|
||||||
|
native boolean requestCursorUpdatesNative(long peer, int cursorUpdateMode);
|
||||||
|
|
||||||
|
native void closeInputConnectionNative(long peer);
|
||||||
|
}
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Builds iris-android-app end to end: the cdylib (cargo ndk, straight into
|
||||||
|
# app/src/main/jniLibs/) then the APK (Gradle). 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`, unlike
|
||||||
|
# an earlier version of this default. `force-gles` (`iris/Cargo.toml`'s
|
||||||
|
# own doc) exists only to force the emulator off its default software
|
||||||
|
# Vulkan and onto GLES for one specific measurement (RUST.md's I5, "Where
|
||||||
|
# iris's frame time goes") -- 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.
|
||||||
|
# That is the named hypothesis in RUST.md's P0 box ("iris bench crash on
|
||||||
|
# the phone, 2026-09-06"): a real Vulkan driver is what a phone should
|
||||||
|
# run, and GLES is the backend the same box's own SwiftShader finding
|
||||||
|
# already flagged as the fragile one for this shader's storage buffers.
|
||||||
|
# Pass `--features "transcript-screen force-gles bench"` explicitly for
|
||||||
|
# an emulator backend-isolation run; never 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"
|
||||||
|
|
||||||
|
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 26 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
||||||
|
else
|
||||||
|
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
GRADLE_TASK="assembleDebug"
|
||||||
|
APK_DIR="app/build/outputs/apk/debug"
|
||||||
|
APK_NAME="app-debug.apk"
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
GRADLE_TASK="assembleRelease"
|
||||||
|
APK_DIR="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
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "9.4.0" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Only does anything under the `transcript-screen` feature (RUST.md's I5
|
||||||
|
// Android integration) -- the plain tabs build (I2/I4) needs none of this
|
||||||
|
// and stays untouched, same reasoning as the feature gate in Cargo.toml.
|
||||||
|
//
|
||||||
|
// Bakes the sandbox server's host, port, token and pinned CA in at build
|
||||||
|
// time, the same way `app/androidApp/build.gradle.kts`'s
|
||||||
|
// `GeneratePinnedCert` task bakes the CA for the Compose app -- see that
|
||||||
|
// file's comment for why reading the machine's own certificate at build
|
||||||
|
// time is the right trust boundary. This build additionally bakes the
|
||||||
|
// host/port/token, which the Compose app does not: that app enrolls at
|
||||||
|
// runtime from a scanned QR/deep link, and a from-scratch enrollment UI
|
||||||
|
// (Keystore-sealed token storage, a QR/link scanner) is real, separate
|
||||||
|
// scope this integration does not need to build to answer RUST.md's
|
||||||
|
// question -- there is nothing here yet resembling `ServerConfig.kt`. So
|
||||||
|
// this is a **deliberate simplification for this rig only**: an APK built
|
||||||
|
// this way is good for exactly the emulator/server pair that built it, and
|
||||||
|
// must never be treated as a template for a real enrollment flow. Recorded
|
||||||
|
// in RUST.md's I5 box rather than left to be rediscovered.
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// P0's bench build (docs/RUST.md) opens the checked-in fixture with no
|
||||||
|
// server at all -- `bench_client.rs` never references the `pinned`
|
||||||
|
// module this generates, so requiring a live server's host/port/token/
|
||||||
|
// CA to build it (as plain `transcript-screen` does, below) would be a
|
||||||
|
// pointless requirement for a build that talks to nothing.
|
||||||
|
if std::env::var_os("CARGO_FEATURE_BENCH").is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST");
|
||||||
|
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT");
|
||||||
|
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN");
|
||||||
|
println!("cargo:rerun-if-env-changed=AI_APP_CA");
|
||||||
|
println!("cargo:rerun-if-env-changed=XDG_CONFIG_HOME");
|
||||||
|
|
||||||
|
let host = require_env(
|
||||||
|
"AI_APP_TRANSCRIPT_HOST",
|
||||||
|
"the sandbox server's host as the emulator reaches it, e.g. 10.0.2.2",
|
||||||
|
);
|
||||||
|
let port = require_env(
|
||||||
|
"AI_APP_TRANSCRIPT_PORT",
|
||||||
|
"the sandbox server's port -- app/ui-sandbox.sh's start banner prints it",
|
||||||
|
);
|
||||||
|
let token = require_env(
|
||||||
|
"AI_APP_TRANSCRIPT_TOKEN",
|
||||||
|
"the bearer token -- ~/.config/ai-app/sandbox-token, or the start banner's enrollment link",
|
||||||
|
);
|
||||||
|
|
||||||
|
let ca_path = std::env::var_os("AI_APP_CA")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let home = std::env::var_os("HOME").expect("HOME must be set");
|
||||||
|
PathBuf::from(home).join(".config")
|
||||||
|
});
|
||||||
|
base.join("ai-app").join("certs").join("ca.pem")
|
||||||
|
});
|
||||||
|
let ca_pem = std::fs::read_to_string(&ca_path).unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"no CA certificate at {} ({e}).\n\
|
||||||
|
Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it \
|
||||||
|
generates the CA this build pins. Set AI_APP_CA=/path/to/ca.pem to build \
|
||||||
|
against a different one.",
|
||||||
|
ca_path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let ca_pem = ca_pem.trim();
|
||||||
|
if !ca_pem.starts_with("-----BEGIN CERTIFICATE-----") {
|
||||||
|
panic!("{} is not a PEM certificate.", ca_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||||
|
let generated = format!(
|
||||||
|
"// Generated by build.rs from {host}:{port} and {ca}. Do not edit.\n\
|
||||||
|
pub const HOST: &str = {host_lit:?};\n\
|
||||||
|
pub const PORT: u16 = {port};\n\
|
||||||
|
pub const TOKEN: &str = {token_lit:?};\n\
|
||||||
|
pub const CA_PEM: &str = {ca_lit:?};\n",
|
||||||
|
host = host,
|
||||||
|
port = port
|
||||||
|
.parse::<u16>()
|
||||||
|
.unwrap_or_else(|e| panic!("AI_APP_TRANSCRIPT_PORT={port:?} is not a u16: {e}")),
|
||||||
|
ca = ca_path.display(),
|
||||||
|
host_lit = host,
|
||||||
|
token_lit = token,
|
||||||
|
ca_lit = ca_pem,
|
||||||
|
);
|
||||||
|
std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_env(name: &str, what: &str) -> String {
|
||||||
|
std::env::var(name).unwrap_or_else(|_| {
|
||||||
|
panic!("{name} must be set to build the transcript-screen feature -- {what}")
|
||||||
|
})
|
||||||
|
}
|
||||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user