Compare commits
282
Commits
main
...
599d33287c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
599d33287c | ||
|
|
b53a17c436 | ||
|
|
de92fccba5 | ||
|
|
25370731d0 | ||
|
|
5428cd75c9 | ||
|
|
8c6e2ed9cf | ||
|
|
4bc23172fd | ||
|
|
e5fee03da8 | ||
|
|
5ece49b8d9 | ||
|
|
e212ed8d02 | ||
|
|
f5183af306 | ||
|
|
fffed42f9e | ||
|
|
0aa03cf621 | ||
|
|
ae0af8f5e3 | ||
|
|
18c5f9aaac | ||
|
|
a428cba41a | ||
|
|
3c7d3db370 | ||
|
|
77cee6a8fa | ||
|
|
43a3a345e4 | ||
|
|
9bf714fa2e | ||
|
|
42d54eec95 | ||
|
|
4ccfda6b8e | ||
|
|
09778346a0 | ||
|
|
6d5a231f5c | ||
|
|
e9a6562dc6 | ||
|
|
afbc2ad132 | ||
|
|
1318e149f5 | ||
|
|
4fdabc39d0 | ||
|
|
bf8658c404 | ||
|
|
00e0a63887 | ||
|
|
b7474f61b0 | ||
|
|
8e5928cc6a | ||
|
|
76fcbdccb9 | ||
|
|
a00376994e | ||
|
|
ba57086361 | ||
|
|
1a9655414e | ||
|
|
5e34dba2fd | ||
|
|
cc8148cbec | ||
|
|
a2e5e5881c | ||
|
|
c121bc0725 | ||
|
|
fe7dc9c728 | ||
|
|
02b277e7ad | ||
|
|
fc82d9d7e8 | ||
|
|
9e301f30c6 | ||
|
|
341b7a5922 | ||
|
|
c8785b6091 | ||
|
|
9c560e3492 | ||
|
|
e5a90c6135 | ||
|
|
af1b0c5ab2 | ||
|
|
cce4b28324 | ||
|
|
94d8373289 | ||
|
|
8310431497 | ||
|
|
b863f9f3df | ||
|
|
cdeb7b0857 | ||
|
|
476609e1d3 | ||
|
|
2756087e1c | ||
|
|
af7d5f3782 | ||
|
|
db0a41a7cd | ||
|
|
b9924e7617 | ||
|
|
f014e8d9cf | ||
|
|
0ccc444246 | ||
|
|
c6da735134 | ||
|
|
4f6ec3a900 | ||
|
|
38bf6309cb | ||
|
|
3eb0e033d5 | ||
|
|
b8ea723718 | ||
|
|
7485d78d50 | ||
|
|
b87f5a597e | ||
|
|
80a75c128e | ||
|
|
50e69995b6 | ||
|
|
85869d02f8 | ||
|
|
f99ae4c366 | ||
|
|
203f53470c | ||
|
|
b38e797db3 | ||
|
|
92985ba8e3 | ||
|
|
181ba64606 | ||
|
|
a6a100edc6 | ||
|
|
ff1d6ea932 | ||
|
|
2b20bb2c91 | ||
|
|
3c80d9d696 | ||
|
|
06b8a1f4b0 | ||
|
|
e10582a2cd | ||
|
|
551c01398f | ||
|
|
7e79ec11e0 | ||
|
|
2ec0fee84c | ||
|
|
992c472975 | ||
|
|
729098756d | ||
|
|
d8562d96a3 | ||
|
|
22210a42f5 | ||
|
|
ade572973a | ||
|
|
9b27e858b5 | ||
|
|
7e4e26a335 | ||
|
|
84a13e806b | ||
|
|
452c44249f | ||
|
|
238057ad5e | ||
|
|
896c93a59a | ||
|
|
690161e5e9 | ||
|
|
e922b73d7a | ||
|
|
d507ae4c96 | ||
|
|
9ed01e2812 | ||
|
|
5be9f1baac | ||
|
|
977bdb9ee0 | ||
|
|
9cd1263080 | ||
|
|
42af780639 | ||
|
|
4274b8b8d0 | ||
|
|
73f956f8e0 | ||
|
|
038f6a3832 | ||
|
|
1121d7cc83 | ||
|
|
232de0ec53 | ||
|
|
e430880cde | ||
|
|
a999bd106a | ||
|
|
6840edf61e | ||
|
|
333220196e | ||
|
|
7f4ea7e8fd | ||
|
|
591128eef1 | ||
|
|
ba0f2ea93f | ||
|
|
ed04d4c735 | ||
|
|
ba2afbaedb | ||
|
|
10267dec27 | ||
|
|
7e7cbb5402 | ||
|
|
a200ddbddd | ||
|
|
b332873894 | ||
|
|
a4809b3026 | ||
|
|
1ad2f9ec6e | ||
|
|
33e8ab83a2 | ||
|
|
f5b88932b4 | ||
|
|
9079276ec8 | ||
|
|
3cb18ac5c2 | ||
|
|
69525bd131 | ||
|
|
64f64b54e5 | ||
|
|
20303e0b4c | ||
|
|
6973a89815 | ||
|
|
c3cfc67bb3 | ||
|
|
155d899e55 | ||
|
|
e63e923d44 | ||
|
|
a56a928b0c | ||
|
|
0449a324ef | ||
|
|
e1030d69f6 | ||
|
|
167862ca1b | ||
|
|
d73db97629 | ||
|
|
fb6b459c2c | ||
|
|
76b1f99277 | ||
|
|
3e72a4ef19 | ||
|
|
c02152a4f4 | ||
|
|
d9872989fa | ||
|
|
2fed8b34b3 | ||
|
|
1f379e8384 | ||
|
|
bf3479f5c4 | ||
|
|
312455956d | ||
|
|
73251d6b8b | ||
|
|
2e00e71552 | ||
|
|
f802de94b5 | ||
|
|
9717d1c4b0 | ||
|
|
9458f443ad | ||
|
|
e12c708246 | ||
|
|
543f6d92f0 | ||
|
|
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 | ||
|
|
7ae53ad797 | ||
|
|
d17040b601 | ||
|
|
bf5087a598 | ||
|
|
aa3d11471f | ||
|
|
78aff64844 | ||
|
|
9a33cb5384 | ||
|
|
45ced405f3 | ||
|
|
62199aa3a7 | ||
|
|
b133d85943 | ||
|
|
ba6817fee5 | ||
|
|
73ee63bc1b | ||
|
|
8f0aec449a | ||
|
|
6d5fd64bb0 | ||
|
|
e5880c33f4 | ||
|
|
a853eb5a4d | ||
|
|
22d5c6585a | ||
|
|
b063fbd7f9 | ||
|
|
3f25e7ebca | ||
|
|
0af4c88d08 | ||
|
|
ceabd00805 | ||
|
|
32a5256a0d | ||
|
|
4cfe0ef6e6 | ||
|
|
c9b273ff16 | ||
|
|
8adda94a7a | ||
|
|
3a9208f38b | ||
|
|
e898370bf4 | ||
|
|
6e0bd06e4d | ||
|
|
03da47e550 | ||
|
|
a2cd119985 | ||
|
|
19c36e37f2 | ||
|
|
e2873df92e | ||
|
|
ea13889a21 | ||
|
|
6317685d1a | ||
|
|
f79bd7ca71 | ||
|
|
9c935f8ce8 | ||
|
|
982449293d | ||
|
|
288853c094 | ||
|
|
fba572427d | ||
|
|
85ec5416b6 | ||
|
|
643daf5637 | ||
|
|
8db0184384 | ||
|
|
1a6599e1b2 | ||
|
|
0a2f4fa1fe | ||
|
|
237886c11e | ||
|
|
e8dbcaa7db | ||
|
|
26163b25b2 | ||
|
|
762c1290a1 | ||
|
|
62dd6b7912 | ||
|
|
bc3db183e3 | ||
|
|
e0a473e090 | ||
|
|
1c937e2f48 | ||
|
|
d194d73439 | ||
|
|
4400966928 | ||
|
|
6e49ce8c92 | ||
|
|
79b9cd789a | ||
|
|
c70a670356 | ||
|
|
43743ba171 | ||
|
|
1a97d0ef5c | ||
|
|
ff7e9c0435 | ||
|
|
68a7f41ed0 | ||
|
|
9b331a5e93 | ||
|
|
3fc224b584 | ||
|
|
10500ae8aa | ||
|
|
8d441d3d59 | ||
|
|
e0ee7d6e94 | ||
|
|
b6b0928087 | ||
|
|
12221ea025 | ||
|
|
5e23c8b0c0 | ||
|
|
caaa733caa | ||
|
|
4ab26f068e | ||
|
|
0f8ba49f4a |
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
||||
# every crate in this repo into one workspace -- they are deliberately
|
||||
# independent (see scripts/run-tests.sh, which cds into each).
|
||||
#
|
||||
# `cargo xtask apk` **from the repo root** runs scripts/xtask/src/main.rs.
|
||||
# The manifest path is relative to the working directory cargo is run from,
|
||||
# so the root is where it works; this file is found from any directory
|
||||
# inside the checkout, but the path inside it is not.
|
||||
[alias]
|
||||
xtask = "run --quiet --manifest-path scripts/xtask/Cargo.toml --"
|
||||
@@ -55,4 +55,23 @@ components: [
|
||||
// the terminal the QR would be printed on.
|
||||
enroll: "server/enroll-link.sh",
|
||||
),
|
||||
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
|
||||
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
|
||||
// with the same release key as "app" above so the two can install
|
||||
// over each other -- a separate component, not a mode of "app" above,
|
||||
// because it is a different applicationId (com.example.aiapp.shell)
|
||||
// built by a different tool from different sources. No `cwd`: it
|
||||
// defaults to this checkout's root, which both the `cargo xtask`
|
||||
// alias (`.cargo/config.toml`, resolved relative to the working
|
||||
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
||||
// step (`scripts/build/outputs/apk/<mode>/*.apk`, matching
|
||||
// discover.rs's `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's
|
||||
// module doc) both need. The publish directory is `scripts/build`
|
||||
// rather than `scripts/xtask/build` for exactly that reason: the
|
||||
// pattern is one directory deep, and the tool moved two on 2026-09-09.
|
||||
Apk(
|
||||
name: "shell",
|
||||
modes: ["release", "debug"],
|
||||
build: "cargo xtask apk",
|
||||
),
|
||||
],
|
||||
+16
@@ -1,12 +1,19 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/androidApp/build/
|
||||
app/shellApp/build/
|
||||
local.properties
|
||||
.kotlin/
|
||||
*.iml
|
||||
.idea/
|
||||
.DS_Store
|
||||
server/target/
|
||||
event-model/target/
|
||||
app-rust/target/
|
||||
|
||||
# E3's native library, built by cargo-ndk straight into the Gradle module
|
||||
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
||||
app/shellApp/src/main/jniLibs/
|
||||
|
||||
# Server logs from a development run (ai-server.log by convention,
|
||||
# wg-test.log from ./test-wg-tunnel.sh).
|
||||
@@ -21,3 +28,12 @@ certs/
|
||||
config.ron
|
||||
config.json
|
||||
sessions/
|
||||
|
||||
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
||||
iris/target/
|
||||
|
||||
# The packaging xtask and the GPU rigs, both under scripts/. `build/`
|
||||
# above already covers scripts/build/outputs/apk, where `cargo xtask apk`
|
||||
# publishes for Dev Updater to find.
|
||||
scripts/xtask/target/
|
||||
scripts/rigs/gpu-probe/target/
|
||||
@@ -5,53 +5,92 @@ replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
||||
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
||||
between them.
|
||||
|
||||
**`PLAN.md` is the design source of truth** — every decision with its date,
|
||||
its rationale, and what was rejected. Read it before changing anything
|
||||
**`docs/PLAN.md` is the design source of truth** — every decision with its
|
||||
date, its rationale, and what was rejected. Read it before changing anything
|
||||
structural, and update it in place when a decision changes rather than
|
||||
letting this file and the plan become two versions of the truth. This file is
|
||||
the working notes layer: layout, commands, and things that have bitten.
|
||||
|
||||
**The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the
|
||||
rule that no UI-driving script may tap a coordinate, how to test llama.cpp and
|
||||
ssh here, how importing behaves, and the measurements not worth re-taking.
|
||||
They moved there on 2026-09-04 because they are 12 KB that only matter once
|
||||
you are actually running one, and this file is sent with every request. Read
|
||||
it before writing or running a benchmark, driving the UI from a script, or
|
||||
touching the import screen.
|
||||
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
|
||||
child process, translated into one common event model.** A new session type
|
||||
is a new driver — never a session-type branch in shared code (routes,
|
||||
transcript, app screens).
|
||||
|
||||
The second one, for the Rust port on the `rustify` branch: **the phone app
|
||||
and a planned desktop app share almost all of their code.** Screens,
|
||||
widgets, folding, paging, config and the network client live in
|
||||
`app-rust/`'s `client` and `ui` modules, drawn with `iris`; `src/android`
|
||||
and `src/desktop` are thin entry points that own only what the platform
|
||||
forces (JNI and the IME on one side, winit and argv on the other). The two
|
||||
*layouts* will differ, to suit a phone's screen and a finger against a
|
||||
desktop's screen and a mouse -- but the widgets a layout is made of (a
|
||||
button, a text field, a list, a card) and the styling (colours, spacing,
|
||||
type) are one implementation with no per-platform copy. Anything that could
|
||||
work on both goes in `ui` the first time it is written, and a platform
|
||||
module growing a widget or a colour is a defect to move, not a convenience
|
||||
to keep. Iris said this on 2026-09-07; docs/RUST.md carries the details.
|
||||
|
||||
The third, from Iris on 2026-09-08: **`iris/` is the UI framework and
|
||||
nothing else.** Nothing in it may know about a session, a transcript, a
|
||||
setup or a server; anything that does belongs in `app-rust/`, and the
|
||||
dependency runs one way only. The port's project code is **one crate**
|
||||
(`ai-app`) rather than the six it was scattered across — see docs/RUST.md's
|
||||
"One app crate" for what forced each of the splits that were removed and
|
||||
the two that remain.
|
||||
|
||||
## Layout
|
||||
|
||||
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
||||
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
|
||||
`:androidApp` module), same cert scheme, same registry pattern. Read
|
||||
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
|
||||
Module-by-module intent is in 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
|
||||
comment is the HTTP table and the surface's source of truth.
|
||||
**A llama.cpp session runs on whatever machine its setup names** (built
|
||||
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
|
||||
port the server binds *there* and the port that reaches it *here*, and
|
||||
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
|
||||
the command. Three things fell out of it and are easy to get wrong again —
|
||||
a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`,
|
||||
because `llama-server` never reads the stdin whose closing ends a CLI and
|
||||
the same kill left it loaded on the far machine; the model is looked for on
|
||||
the machine that will serve it, so the spawn screen offers
|
||||
`GET /setups/{id}/models` rather than the backend's own downloads; and the
|
||||
readiness poll watches the process as well as the port, since a model that
|
||||
will not load exits in a second and was being reported as "gave up after
|
||||
300s". See PLAN.md's "Transport" and "llama-server management".
|
||||
- `event-model/` — the wire shape `server/` and `app-rust/` both depend on,
|
||||
which is the whole reason it is a crate of its own rather than part of
|
||||
either: it is the contract between them, so the two agree by construction.
|
||||
- `app-rust/` — the Rust app, one crate (`ai-app`) with three faces. `src/
|
||||
client` is everything with no UI in it (the REST and SSE clients, the
|
||||
transcript cache and fold, the highlighter, the ANSI parser, config and
|
||||
the enrolment link); `src/ui` is the screens as iris widget trees;
|
||||
`src/desktop` + `src/bin_desktop.rs` is the winit binary; `src/android`
|
||||
is the `android-view` entry point and `android-project/` its Gradle app;
|
||||
`src/shell` is the separate JNI bridge the Kotlin `app/shellApp` calls.
|
||||
Features pick which face a build is: `screens` (default) for anything
|
||||
that draws, `shell` for the Compose app's bridge, `bench` for P0's
|
||||
fixture build. See its `Cargo.toml` header.
|
||||
- `iris/` — the UI framework, and **only** the UI framework: `core`,
|
||||
`macro`, the `iris` crate itself, `tabs-ui` (its own demo widget tree)
|
||||
and `rig-input`. It must not mention anything this product is about.
|
||||
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
||||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
||||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
||||
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
||||
the Keystore-sealed token.
|
||||
- `scripts/` — everything at the root that was neither a program nor a
|
||||
document: the three repo-wide shell scripts (`run-tests.sh`,
|
||||
`test-wg-tunnel.sh`, `wg-setup-host.sh`), `rigs/` (the `gpu-probe` and
|
||||
`virtgpu-probe` device probes, and `ui-profile`'s two layer-1
|
||||
profiling rigs), and `xtask/`. **A project's own scripts
|
||||
stay with the project** — `app/*.sh`, `app-rust/*.sh`, `iris/*.sh` and
|
||||
`server/enroll-link.sh` did not move (Iris, 2026-09-09: "I only meant
|
||||
top level sh files").
|
||||
`scripts/xtask/` is the [cargo-xtask](https://github.com/matklad/cargo-xtask)
|
||||
convention: an ordinary Rust binary that does build work a shell script
|
||||
would otherwise do, run as `cargo xtask apk` **from the repo root**
|
||||
(`.cargo/config.toml`'s alias, whose `--manifest-path` is relative to
|
||||
the working directory). It packages `app/shellApp` without Gradle
|
||||
driving it — `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` →
|
||||
`apksigner` — and publishes to `scripts/build/outputs/apk/<mode>/`,
|
||||
which is where Dev Updater looks. There is deliberately **no `target/`
|
||||
at the repo root** any more: there is no workspace there, and what used
|
||||
to be one was only xtask's own scratch space, now in
|
||||
`scripts/xtask/target/`.
|
||||
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
||||
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
||||
binding and the certificate's SANs (`netif`), owner-only files (`private`),
|
||||
@@ -61,19 +100,61 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
||||
projects version-locked to the commit this repo pins. What deliberately did
|
||||
**not** move is the API surface and the config *schema*: routes, drivers,
|
||||
sessions and setups are what makes this project itself.
|
||||
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
|
||||
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
|
||||
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
|
||||
choices made there that are still awaiting review.
|
||||
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
|
||||
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||||
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
|
||||
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
|
||||
and stream effects in `SessionScreen.kt`.
|
||||
- `TODO.md` — the working list.
|
||||
- `docs/` — every design and working document except this file and
|
||||
`CLAUDE.md`:
|
||||
- `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs`
|
||||
and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||||
- `docs/TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent.
|
||||
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
|
||||
the opening and stream effects in `SessionScreen.kt`.
|
||||
- `docs/TODO.md` — the working list.
|
||||
- `docs/SUBAGENTS.md` — a session's subagents: the wire shape, the
|
||||
phone's view, and the choices behind the shape.
|
||||
- `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
|
||||
branch of the `ai-app-2` clone): what has to be reproduced, the
|
||||
framework decision, and the ordered experiments with their pass
|
||||
conditions. Read it before touching anything under that branch.
|
||||
- `docs/IRIS_TODO.md`, `docs/LAYOUT.md`, `docs/TEXTURES.md`,
|
||||
`docs/CLIENT_CORE.md` — iris's open working list, its layout/render
|
||||
design, its texture-atlas design, and the design of `app-rust`'s
|
||||
`client` module, respectively.
|
||||
|
||||
**These documents are pruned as the work lands, not appended to
|
||||
forever** (Iris, 2026-09-08: *"remove everything that's already done and
|
||||
decided… many with checkboxes already ticked off that just fill up
|
||||
context"*). A ticked box, a finished experiment and a completed review
|
||||
are deleted once carried out; `IRIS_TODO.md` holds only open items, and
|
||||
a finished document is removed rather than archived in place. What
|
||||
survives is what cannot be cheaply re-derived — measurements, dead ends
|
||||
and failed hypotheses, invariants and their reasons, and the design of
|
||||
what exists now rather than the route to it.
|
||||
|
||||
**There is no decisions log and no design log, and one should not be
|
||||
started.** `docs/DECISIONS.md` and `docs/IRIS.md` were deleted on
|
||||
2026-09-09 at Iris's instruction: *"I've decided to instead make
|
||||
decisions when planning with agents rather than after they do things,
|
||||
and they're both too long for me to wanna read, + don't cover all the
|
||||
decisions I'll wanna make about the code anyways. I'll just naturally
|
||||
run into things for now."* So raise a choice **while planning it with
|
||||
her**, when the direction is still cheap to change; otherwise decide it,
|
||||
put the reasoning at the code it governs, and carry on. TODO lists are
|
||||
still wanted — a list of open work is useful, a list of finished work
|
||||
is not.
|
||||
- `docs/SCROLL.md` — how anything in iris scrolls: one
|
||||
`ScrollController` holds the position, the gesture, the fling and the
|
||||
pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own
|
||||
one each through the `Scrollable` trait. Read it before touching
|
||||
`scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that
|
||||
pans, flings or lays out a long list.
|
||||
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
||||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
||||
rather than a script kept here) and the APK, in parallel. It points at
|
||||
rather than a script kept here), the Compose app and the shell APK, in
|
||||
parallel. **It does not publish either benchmark APK.** Phone benchmark
|
||||
builds live in the separate `~/repos/ai-app-bench` repository: build here,
|
||||
copy the verified artifact to that repo's `compose/` or `iris/` Gradle-shaped
|
||||
path, then commit and push that repo. Dev Updater pulls the committed APK
|
||||
from there; pushing `ai-app-2` alone cannot update its benchmark card. This
|
||||
file points at
|
||||
`resources.ron`, which is *ours* rather than Dev Updater's — it names
|
||||
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
|
||||
offer them. Note what deleting the config directory takes with it: the CA
|
||||
@@ -96,18 +177,43 @@ the **Mono** face, where every glyph is one em square, which is what makes
|
||||
two icon buttons the same width without either being given one — and why
|
||||
`GLYPH_SIZE` is smaller than it looks like it should be.
|
||||
|
||||
**The Rust app does the same, from its own subset**:
|
||||
`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`,
|
||||
with the codepoints named in `iris/core/src/icon.rs` and drawn as text
|
||||
with `Family::Icons`. Same rule about the two lists agreeing (there is a
|
||||
test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material
|
||||
Design family so an icon means the same thing in both apps. Its subset is
|
||||
separate rather than shared because subsetting only what one app draws is
|
||||
the point. This is the **only** font iris bundles — body and monospace
|
||||
text come from the platform (decided 2026-09-07), and an icon
|
||||
is the opposite case: a small closed set of codepoints no system font is
|
||||
guaranteed to have.
|
||||
|
||||
## Checking your work
|
||||
|
||||
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
|
||||
`server/`), plus `cargo clippy --all-targets` and `cargo fmt`. The build
|
||||
stays warning-clean and rustfmt-clean at the defaults — there is no
|
||||
`rustfmt.toml` and there should not be one.
|
||||
- **Commit completed work.** Once a coherent piece of work has passed its
|
||||
relevant checks and has no known major issue or unresolved design decision,
|
||||
commit it rather than leaving it in the worktree. Keep independently
|
||||
completed slices in separate commits.
|
||||
|
||||
- **Rust**: `./scripts/run-tests.sh` from the repo root runs `event-model`,
|
||||
`server/` and `app-rust/`; `cd iris && cargo test` runs the framework's
|
||||
own suite, which is slower and not about this product. Each workspace
|
||||
also gets `cargo clippy --all-targets` and `cargo fmt`. The build stays
|
||||
warning-clean and rustfmt-clean at the defaults — there is no
|
||||
`rustfmt.toml` and there should not be one. `app-rust/`, `iris/`, and the
|
||||
UI profiling rig use the rolling nightly channel through per-directory
|
||||
`rust-toolchain.toml` files; `server/` and `event-model/` are stable.
|
||||
- **App**: from `app/`,
|
||||
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||||
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
||||
syntax highlighter, the ANSI parser and the transcript cache — the app's
|
||||
pure logic with no Android in it.
|
||||
pure logic with no Android in it. Touching anything under `BenchFixture.kt`,
|
||||
`BenchNetwork.kt`, `BenchRun.kt` or the `bench` build type also needs
|
||||
`:androidApp:compileBenchKotlin :androidApp:lintBench` — a second build
|
||||
type compiles separately and lint has caught real bugs debug alone never
|
||||
would (see "Android Lint" below).
|
||||
- **Android Lint is not optional and is not run by a build.** It found a
|
||||
crash that had been shipping (`java.time` on a minSdk-24 app with
|
||||
desugaring off) and later a permission check that silently dropped every
|
||||
@@ -158,20 +264,293 @@ two icon buttons the same width without either being given one — and why
|
||||
whether it was continued or reset (`stream backlog:`). That is the only
|
||||
place "how far had this phone fallen behind" is answerable — the app sees a
|
||||
window arrive and cannot tell.
|
||||
- **`./test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
|
||||
- **`./scripts/test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
|
||||
network namespaces inside one machine and drives the server through it — a
|
||||
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
||||
involved. That is how to verify the wg0-only posture.
|
||||
|
||||
## The rigs
|
||||
|
||||
Each exists because something was invisible without it.
|
||||
|
||||
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
|
||||
and the 2026-09-05 decisions), the phone benchmark gate Iris
|
||||
asked for before porting continues: a deterministic, checked-in synthetic
|
||||
transcript (`app/bench-fixture/generate.py`, never a real one) that both
|
||||
this app and iris open with no server, so a frame-time comparison
|
||||
measures the renderer rather than the data. `./build-apk.sh bench` builds
|
||||
it — own application id (`com.example.aiapp.bench`) and label ("AI
|
||||
Sessions bench") so it installs beside a real enrollment rather than
|
||||
replacing it. Opening it goes straight to a session screen holding the
|
||||
fixture (no enrollment, no permission prompts) with a "Run benchmark"
|
||||
control beside "Copy" in session settings: it drives the same scroll loop
|
||||
and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over
|
||||
`ui-trace`, but in-process, since a real phone has no usable system
|
||||
tracing and no agent can drive one (this-machine-android's skill).
|
||||
`BenchFixture.kt`/`BenchNetwork.kt` fake the backend by installing a
|
||||
`URLStreamHandlerFactory` that answers `TranscriptSource`/`EventStream`'s
|
||||
requests from an in-memory copy of the fixture instead of opening a
|
||||
socket — so the fold, the paging and `uniqueItems` under test are the
|
||||
screen's real ones, never a shortcut built just for this. The report
|
||||
gains a `bench:` section (process CPU time, peak RSS, battery current) on
|
||||
every build, empty except when `BenchRun.kt` filled it in.
|
||||
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
|
||||
and data directory, holding eight invented Claude Code transcripts and a
|
||||
`claude` that is two lines of shell. **That isolation is the point**: the
|
||||
import screen lists whatever is in `~/.claude/projects`, which in this VM is
|
||||
real agent transcripts, so exercising *delete* against the ordinary server
|
||||
deletes somebody's conversation and exercising *import* starts a real
|
||||
`--resume` on the owner's account.
|
||||
Its port and root derive from the checkout's name, so two checkouts'
|
||||
sandboxes cannot reach each other, and its token is generated once into
|
||||
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
|
||||
the enrolment flow appended — so the emulator app is enrolled **once** (the
|
||||
start banner prints the command) and stays enrolled. It shares the real TLS
|
||||
certificates, because the installed APK pins that CA.
|
||||
Driving verbs, so none of this is re-derived per session:
|
||||
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
|
||||
`./ui-sandbox.sh send SID text|@file`, and
|
||||
`./ui-sandbox.sh api /path [curl args]`.
|
||||
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
|
||||
enrolment already there — for when the fixture under test was expensive to
|
||||
build; plain `start` wipes them, which is right for the list-screen
|
||||
fixtures and wrong for that.
|
||||
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
|
||||
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
|
||||
fake CLI slow to start. Both exist because operations that finish in
|
||||
milliseconds have states on the way that nothing can observe, and an
|
||||
unobservable state is one where broken and working look identical.
|
||||
It also builds a fixture tree at the sandbox home's `~/files` for the
|
||||
explorer, holding the states otherwise only reachable by finding a real
|
||||
machine in one: an empty directory, a name with a tab and one with an
|
||||
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
|
||||
symlink to a directory and a broken one, a source file per language, and
|
||||
the three sizes the limits were measured against (`edit-32k.rs`,
|
||||
`edit-128k.rs`, `big-source.rs`). Point a session at it with
|
||||
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
|
||||
The explorer's 409 is produced by editing the file on the machine
|
||||
(`printf … > file`) between pressing the pencil and pressing save.
|
||||
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
|
||||
echo driver is the right rig for most things and the wrong one for anything
|
||||
whose cost scales with what was actually written: a real reply is longer,
|
||||
is real markdown, and carries tool calls whose input and output are
|
||||
kilobytes. Two faults were invisible until a real transcript was loaded — a
|
||||
page of history landing mid-fling threw the reader back to the newest end,
|
||||
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
|
||||
`-b` takes the biggest conversation on the machine rather than the newest,
|
||||
which is what a scrolling test wants; `--stop` takes it down.
|
||||
It copies the transcript into `/tmp` and gives the server a `HOME` of its
|
||||
own, so the import can only see the copy — importing spawns `claude
|
||||
--resume`, and against the real file that is a second CLI writing to a
|
||||
conversation somebody may still be in. **A transcript never goes in this
|
||||
repository**: they hold whatever was said, read and written in that
|
||||
session, and `~/repos` is shared with the host besides.
|
||||
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
||||
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
|
||||
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
|
||||
it holds the fifo open, records a real pid, writes nothing, and dies on a
|
||||
signal. So adopt, stop, restart and start are all drivable without a real
|
||||
`--resume` and without spending a turn on somebody's account. Reach for
|
||||
this when what is under test is *whether a process is running*, and for
|
||||
`debug-transcript.sh` when it is *what the transcript draws*.
|
||||
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
|
||||
the first session (or `-k` keeps the current screen), scrolls a fixed
|
||||
gesture loop, and prints the app's render report — the same one the in-app
|
||||
copy button produces, whose `on screen:` line names what the viewport was
|
||||
holding. Compare two runs with the same gestures; the emulator's absolute
|
||||
frame times transfer nothing, the report's accounting does. Run it either
|
||||
side of any change under `Markdown*.kt`, `Transcript*.kt` or
|
||||
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
|
||||
that move first are the worst `record: one block`, the reparse mean while
|
||||
streaming, and the draw phase's accounting line.
|
||||
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
|
||||
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
|
||||
resets the report, sends FILE, waits for the transcript to stop growing,
|
||||
and prints. Both of those are corrections to a first version that measured
|
||||
nothing: a transcript parked further back never redraws while a reply
|
||||
streams into it, and a session is idle at *both* ends of a turn, so polling
|
||||
for idle answers before the turn has started.
|
||||
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
|
||||
framework, from `atrace` text output with no trace processor needed. It is
|
||||
how the cost of a layout node per link was attributed to the framework
|
||||
rather than guessed at.
|
||||
- **`app-rust/build-apk.sh [debug|release] [--abi ...] [--features
|
||||
...]`** builds the Rust app's cdylib (`cargo ndk` from `app-rust/`,
|
||||
straight into `android-project/app/src/main/jniLibs/`) and its APK
|
||||
(Gradle, from `android-project/`) in one step and verifies the result
|
||||
(`aapt2`/`apksigner`), and **`app-rust/run-bench.sh [--apk PATH]`**
|
||||
installs it on this checkout's own emulator, taps "Run benchmark" by
|
||||
label, and prints the report -- written so the P0
|
||||
build/install/tap/read-report cycle stops being retyped by hand each
|
||||
time (docs/RUST.md's P0 box). It passes `--no-default-features`, so
|
||||
`--features` alone decides what is in the `.so`; that is what keeps the
|
||||
1.9 MB bench fixture out of a build that did not ask for `bench`. A phone
|
||||
build is published only by replacing
|
||||
`~/repos/ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`
|
||||
and pushing the **bench repository**, not this checkout.
|
||||
- **iris's three test layers** (docs/RUST.md's "Three test layers" has
|
||||
the commands and what each cannot answer): test at the cheapest one
|
||||
that can answer the question. `cd app-rust && cargo test` runs the real
|
||||
transcript screen over the bench fixture with **no window, no
|
||||
compositor and no GPU** (`iris::harness`), on a clock the test owns and
|
||||
a gesture replayed from a `t_ms action x y` file under
|
||||
`app-rust/touch/` -- which is how the batched 120Hz
|
||||
flick a finger actually makes is testable at all, since a `ui-trace`
|
||||
swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone
|
||||
--dir ../app-rust --shot …` opens the same screen in a window at the
|
||||
phone's own size and density for looking at, and `--replay FILE` drives
|
||||
the same recording into it (`--dir` names the workspace to build in,
|
||||
since the rig lives in iris and the app's examples do not). The emulator is for JNI, the IME, insets, the surface
|
||||
lifecycle and one verification run before a build goes to the phone --
|
||||
not for iterating on layout.
|
||||
- **`scripts/rigs/ui-profile/`** holds the two layer-1 profiling rigs, in
|
||||
a crate of their own so a rig's dependencies stay out of the app's
|
||||
(Iris, 2026-09-09: *"Rigs should probably all be in their own crate so
|
||||
dependencies and such don't get mixed"*). Run either from that
|
||||
directory; both are `#[ignore]`d and assertion-free, so `run-tests.sh`
|
||||
neither runs them nor can fail on them, and both need **release or the
|
||||
numbers mean nothing**.
|
||||
- **`tests/frame_profile.rs`** is what a frame costs on the CPU,
|
||||
at layer 1 -- `cargo test --release --test frame_profile -- --ignored
|
||||
--nocapture`. Two runs: a fling over the bench
|
||||
fixture eight times out and back, and a reply streaming into it one
|
||||
event at a time. Text shaping dominates, which is why the profile is
|
||||
meaningless unoptimised. It cannot answer anything about the GPU, the
|
||||
swapchain or the phone's own clock.
|
||||
|
||||
What it established on 2026-09-09, worth not re-deriving. A **fling**
|
||||
is not CPU-bound: only about one frame in six lays anything out (the
|
||||
rest are moved on the GPU through `move_offsets`), and the
|
||||
multi-millisecond spikes are all in the *first* pass over a stretch of
|
||||
transcript -- every later pass over the same rows is p99 0.26ms. A
|
||||
**streamed event** is, and it is not where it looks: folding the event
|
||||
is 0.35ms and applying the diff to the widget tree is 0.41ms, while the
|
||||
*frame* is 3.86ms here and 9.5ms on Iris's phone. (The fold was the
|
||||
hypothesis, from `foldEvent`'s Compose lesson under "Things that have
|
||||
bitten"; measuring it is what ruled it out.) That frame is one
|
||||
`TextBuffer::shape` of the block a delta landed in, and **the fixture's
|
||||
is 14,888 characters in a single block** -- against a largest-ever
|
||||
1,580 across 7,706 blocks of real replies. So the stream phase's number
|
||||
is a property of the fixture, not of streaming; docs/RUST.md's
|
||||
"Incremental text" has the measurements and why parley cannot help.
|
||||
|
||||
The last two runs (`where_a_streamed_deltas_cost_is`,
|
||||
`what_the_fixture_streams`) exist to keep that answerable: what a delta
|
||||
costs to re-split and re-compare, and what the fixture actually
|
||||
streams.
|
||||
- **`tests/arena_churn.rs`** is what a frame costs to *upload* -- the half
|
||||
of a frame layer 1 builds and never performs, and so the half
|
||||
`frame_profile.rs` cannot see at all. It prints three numbers per GPU
|
||||
array per frame, and the point of the rig is that no two of them alone
|
||||
are honest: **floor** (entries whose bytes actually differ, found by
|
||||
diffing), **uploaded** (what iris really writes, read from the same
|
||||
`Dirty` sets `UiRenderNode::update` consumes), and **whole** (what the
|
||||
old code wrote whenever anything changed). A gap between the first two
|
||||
is over-marking; one was 122x and invisible until both were printed
|
||||
side by side.
|
||||
|
||||
What it established on 2026-09-09, and what the three optimisations it
|
||||
drove were. Uploading the whole arena on any change cost **758 MB over
|
||||
a fling and 1.2 GB over 401 streamed deltas**, p50 3.0 MB per streamed
|
||||
frame. Three things were wrong and each is now guarded by this rig:
|
||||
`ArrBuf` reallocated on every length change, so adding one glyph made
|
||||
the buffer's contents undefined and forced a full rewrite; a redraw
|
||||
freed its primitives and pushed new ones, which -- since freed slots
|
||||
are only reusable next frame and provisional layout nested -- grew
|
||||
the arena to **127,443 slots for 11,569 live primitives**; and nothing
|
||||
tracked *which* entries changed. Now: the stream arena is 11,569 slots
|
||||
for 11,569 live, and every array uploads within a hair of its floor.
|
||||
The CPU half improved with it, since the freeing and renumbering
|
||||
went away: a streamed frame is p50 1.39ms, from 2.20ms.
|
||||
|
||||
The remaining layout cost was then removed at the framework boundary:
|
||||
`Painter::set_child_offset` gives a container one retained coordinate slot
|
||||
for its child subtree, and `LazySpan` keeps row boxes stable behind it.
|
||||
Pinned growth now uploads instances at **1.1% against a 1.1% floor**, from
|
||||
71.9% against 71.8%; p50 instance upload is **1,488 bytes**, from 176,496.
|
||||
`Primitives` also cancels dirty marks for provisional writes restored before
|
||||
upload, so CPU-only layout states never become GPU work.
|
||||
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
|
||||
docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader
|
||||
in software -- while its GLES *is* the host's real GPU through virgl at
|
||||
ES 3.1, so an ordinary build's runtime fallback lands there by itself
|
||||
and nothing should pass `force-gles` to arrange it. The Vulkan path is
|
||||
verified on the desktop build and on Iris's phone. Do not boot the
|
||||
emulator with SwiftShader Vulkan to "test the Vulkan path": that
|
||||
measures a software rasteriser and steers iris away from the one
|
||||
hardware-accelerated backend it has there. Every run says which adapter
|
||||
drew it (`iris renderer:` in logcat, printed by `run-bench.sh`); read
|
||||
that line before reading a number.
|
||||
|
||||
### 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
|
||||
|
||||
The prebuilt CPU llama.cpp lives outside the repo at
|
||||
`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its
|
||||
own directory on `LD_LIBRARY_PATH`, so start the server as
|
||||
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
|
||||
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
|
||||
usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the
|
||||
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
||||
broken driver — `llama-cli` produces the same from the file directly, which
|
||||
is how to tell the two apart in a hurry.
|
||||
|
||||
There is no second machine, so **ssh this VM to itself**: generate a
|
||||
throwaway key, append the public half to `~/.ssh/authorized_keys`, and
|
||||
configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus
|
||||
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches
|
||||
nothing real. Point a provider's `command` at something harmless like
|
||||
`/bin/echo` rather than at `claude`: the transport is what is under test, the
|
||||
process exiting immediately is the signal, and it costs no tokens. **Take the
|
||||
key back out afterwards.** The remote login shell here is **fish**; the
|
||||
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
|
||||
both, but that is luck rather than design, and a shell that is neither is the
|
||||
thing to suspect first if a remote spawn ever mangles an argument.
|
||||
|
||||
## Where things run (host vs this VM)
|
||||
|
||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
||||
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that
|
||||
means here:
|
||||
This checkout runs in a VM while production runs on its host:
|
||||
|
||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||||
address the phone can reach is, and where WireGuard terminates.
|
||||
`wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
||||
`scripts/wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
||||
there with `sudo WG_ENDPOINT=<ddns name>`.
|
||||
- **The tunnel and the real phone can never terminate in the VM**, because
|
||||
nothing outside can open a connection into it. Phone bring-up is host work.
|
||||
@@ -186,7 +565,7 @@ means here:
|
||||
## Sessions outlive the backend
|
||||
|
||||
Since 2026-08-29 a session's process is deliberately left running when
|
||||
`ai-server` stops, and adopted again when it starts. PLAN.md has the design;
|
||||
`ai-server` stops, and adopted again when it starts. docs/PLAN.md has the design;
|
||||
day to day:
|
||||
|
||||
- **Stopping the server no longer stops the sessions.** After `pkill
|
||||
@@ -209,24 +588,42 @@ day to day:
|
||||
in `process.json`; removing either by hand while the session is live loses
|
||||
output or replays it.
|
||||
|
||||
## Auto-resume
|
||||
## Importing
|
||||
|
||||
**A session switched to it sends itself a message once the account's usage
|
||||
limit lifts** — off by default, per session, in the session settings dialog.
|
||||
PLAN.md's "Auto-resume" is the design; day to day:
|
||||
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.
|
||||
|
||||
- **The schedule is a plan to ask.** `resume.rs` wakes at the scheduled time,
|
||||
asks `GET /usage`'s meter for that machine and provider, and only sends when
|
||||
it answers `ok` with nothing at 100%. Anything else — still spent, logged
|
||||
out, unreachable — is a longer wait, and a still-spent window reschedules to
|
||||
the reset time the *meter* now gives.
|
||||
- **Test it with echo, never with a real account.** `/limit [minutes]` reports
|
||||
the same `limitReached` event a real driver does, and `/usage 100 5` sets
|
||||
what the meter answers. They are deliberately separate: the two disagreeing
|
||||
is the case the design exists for. `/usage 20` is the limit lifting.
|
||||
- The wait is on the session in `config.ron` (`resume`), so it survives a
|
||||
backend restart. A day after the limit was hit it gives up and says so in
|
||||
the transcript.
|
||||
**Never import a Claude Code session that is open in a terminal.** The app
|
||||
refuses it — see docs/PLAN.md for the incident that made that a refusal rather
|
||||
than a warning.
|
||||
|
||||
**One Claude Code session id can name two files, and the listing offers it
|
||||
once.** Resuming from a different working directory makes the CLI write a
|
||||
second transcript with the same id under that directory's project folder — an
|
||||
ordinary state of a machine, not corruption. Everything downstream addresses
|
||||
a session by id, and the phone keyed its list on it, so two rows sharing one
|
||||
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
|
||||
the copy with the most lines, because the other is usually a few-hundred-byte
|
||||
stub and is often the *newer* of the two, so recency is the wrong key.
|
||||
Deleting removes every copy rather than the first, or the row came back after
|
||||
a delete that reported success. The phone's half is `uniqueItems`, which
|
||||
every list keyed on a server-chosen id goes through: a repeat there must
|
||||
never be able to close the app, whatever produced it.
|
||||
|
||||
**Deleting a session offers to take the machine's own transcript with it** —
|
||||
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
||||
confirmation, and only where the driver keeps a record of its own
|
||||
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
|
||||
because leaving that copy is what makes an ordinary delete recoverable — and
|
||||
the dialog's paragraph is rewritten when it is on rather than appended to,
|
||||
since the sentence promising the conversation "should still be there to
|
||||
import again" is exactly the one the switch makes false. The server deletes
|
||||
the machine's copy *first*, so a machine it cannot reach leaves the session
|
||||
where it was instead of half-deleted.
|
||||
|
||||
## Shared appearance
|
||||
|
||||
@@ -239,21 +636,9 @@ PLAN.md's "Auto-resume" is the design; day to day:
|
||||
swallowed the drag along with the tap, so a list could not be scrolled
|
||||
while anything in it was busy.
|
||||
|
||||
- **A rate-limit bar belongs to a session's provider, not to its machine.**
|
||||
One machine offers echo, the Claude CLI and a local model at once and only
|
||||
the CLI spends anything, so a session says which meter reports on it
|
||||
(`usageProvider`, from `DriverKind::usage_provider`, which
|
||||
`usage::providers_for` reads too so the two lists cannot disagree) and the
|
||||
phone matches a snapshot on machine *and* provider. Nothing meters a llama
|
||||
or echo session, and the phone draws **nothing** for one — not a zero, and
|
||||
not "unknown". Nothing while the first fetch is out either: "checking"
|
||||
under a session that turns out to meter nothing is a row the screen then
|
||||
has to withdraw.
|
||||
|
||||
## Things that have bitten
|
||||
|
||||
Project-specific only — a lesson that would bite any project on this machine
|
||||
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
||||
Project-specific only; keep cross-project machine notes out of this file.
|
||||
|
||||
- **tracing caches callsite interest process-wide.** A test that hits a
|
||||
`tracing::warn!` with no subscriber installed can poison the interest cache
|
||||
@@ -355,3 +740,37 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
||||
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
|
||||
that wraps the *fetch* and leaves the work done with the result outside it.
|
||||
|
||||
## Measurements worth not re-taking
|
||||
|
||||
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
|
||||
emulator against a real imported transcript with the server at
|
||||
`--delay 120`. Settled and flinging fast, both into fresh history and back
|
||||
through rows already drawn: **5.2–5.9% janky frames, 99th percentile
|
||||
29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same
|
||||
device is 3.3% and 38ms, so this is at the platform floor. The number that
|
||||
is *not* at the floor is the first few seconds after opening a session,
|
||||
where every row on the way is being composed for the first time; that is
|
||||
inherent to a lazy list and it is why a measurement taken before the screen
|
||||
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
|
||||
- **The reset path is not reachable by reopening a session.** Measured
|
||||
2026-09-04 against a session streaming at 20 events a second: reopening one
|
||||
with an anchor 1,800 events back connects **87–119 events behind**, well
|
||||
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
|
||||
opening page, then one span covering the whole distance. To exercise the
|
||||
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
|
||||
the app takes the reset on a live connection, clears, refills and carries
|
||||
on without reconnecting.
|
||||
- **The session screen's stream survives backgrounding here** — 20 seconds at
|
||||
the launcher while 415 events were produced brought no reconnect at all,
|
||||
which is not what the comment above that loop expects, and is most likely
|
||||
this emulator being headless rather than the phone's behaviour.
|
||||
- **Reopening a cached session costs one request for one event** (the probe),
|
||||
and scrolling the whole conversation back costs nothing more; a cold open
|
||||
of the same 500-event session is two pages, 100 events. Measured
|
||||
2026-09-04 on the emulator against the sandbox.
|
||||
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
|
||||
28,000-line file because it draws one row per line; the editor is one
|
||||
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
|
||||
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
|
||||
screen. If you make the editor faster, that number is what to move.
|
||||
docs/EXPLORER.md's "What the measurements said" has the rest.
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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,35 +0,0 @@
|
||||
# TODO
|
||||
|
||||
Working list from Iris, 2026-09-03. Remove an entry when it lands; annotate
|
||||
one in place when it turns out to need a decision.
|
||||
|
||||
## App — transcript
|
||||
|
||||
- [ ] Messages received from other agents are inconsistent — sometimes they
|
||||
appear, sometimes they don't. **Needs a rig.** Read the code rather than
|
||||
measured: a live Claude session only learns of a peer message from the
|
||||
`origin` object on a turn's `result`
|
||||
(`session/claude/translate.rs`), which the CLI attaches to a turn the
|
||||
message *started*. So a message that arrives mid-turn, or a second one
|
||||
within one turn, has nowhere to be reported — while an imported session,
|
||||
which syncs from the CLI's own file, picks up every one of them. That
|
||||
would show exactly as "sometimes". Confirming it means driving a real
|
||||
stream-json session and sending it messages in both states.
|
||||
|
||||
## Session settings
|
||||
|
||||
- [ ] Autocompact belongs in session settings; empty disables it, which is the
|
||||
default. Iris chose "hand it to the driver" — only where a driver has
|
||||
auto-compaction of its own. **That option was offered on a false premise
|
||||
and is not buildable yet.** It named pi's `set_auto_compaction`, but pi
|
||||
was never built as a driver here: `session/llama.rs` talks to
|
||||
`llama-server`'s OpenAI-compatible endpoint directly, and its `compact()`
|
||||
refuses outright. Claude Code's auto-compaction is the CLI's own and
|
||||
nothing in the stream-json control protocol this app uses configures it.
|
||||
So the setting would be stored, passed to a driver, refused by every one
|
||||
of them, and the field would never appear on any session. What is needed
|
||||
first is either a driver that can take it, or a different rule — the
|
||||
server watching `contextTokens` and running `/compact` itself is the one
|
||||
that would work today, for Claude sessions, and it is the option that was
|
||||
not chosen.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
android-project/.gradle/
|
||||
android-project/build/
|
||||
android-project/app/build/
|
||||
|
||||
|
||||
# Rebuilt by `cargo ndk -o app/src/main/jniLibs/ build` before every
|
||||
# Gradle build -- see RUST.md's I2 for the exact command.
|
||||
android-project/app/src/main/jniLibs/
|
||||
target/
|
||||
Cargo.lock.orig
|
||||
Generated
+5202
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,109 @@
|
||||
# Product code lives here; reusable UI belongs in `iris/`.
|
||||
[package]
|
||||
name = "ai-app"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# Android loads the cdylib; desktop, examples, and tests link the rlib.
|
||||
[lib]
|
||||
name = "ai_app"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "ai-app-desktop"
|
||||
path = "src/bin_desktop.rs"
|
||||
required-features = ["screens"]
|
||||
|
||||
[[example]]
|
||||
name = "transcript"
|
||||
required-features = ["screens"]
|
||||
|
||||
[[example]]
|
||||
name = "phone"
|
||||
required-features = ["fixture"]
|
||||
|
||||
[dependencies]
|
||||
event-model = { path = "../event-model" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# Transcript lines must retain exact float values and raw JSON bytes.
|
||||
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
pulldown-cmark = "0.13.4"
|
||||
base64 = "0.23"
|
||||
log = { version = "0.4.34", features = ["std"] }
|
||||
|
||||
# Optional so the Compose shell does not link the renderer.
|
||||
iris = { path = "../iris", optional = true }
|
||||
tabs-ui = { path = "../iris/tabs-ui", optional = true }
|
||||
jni = { version = "0.22", optional = true }
|
||||
libc = { version = "0.2.189", optional = true }
|
||||
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
|
||||
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
winit = "0.30.13"
|
||||
|
||||
# Keep this pin synchronized with `iris/Cargo.toml`.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||
android_logger = "0.15.1"
|
||||
|
||||
[features]
|
||||
default = ["screens", "fixture"]
|
||||
screens = ["dep:iris"]
|
||||
# Default-on for tests; APK builds opt in so ordinary APKs omit the 1.9 MB fixture.
|
||||
fixture = ["screens"]
|
||||
transcript-screen = ["screens"]
|
||||
tabs-screen = ["screens", "dep:tabs-ui"]
|
||||
bench = ["transcript-screen", "fixture", "dep:libc", "dep:tokio"]
|
||||
shell = ["dep:jni"]
|
||||
force-gles = ["screens", "iris/force-gles"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.53.1", features = ["rt", "time"] }
|
||||
|
||||
# APK builds select these profiles explicitly.
|
||||
[profile.android-release]
|
||||
inherits = "release"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
# A warm-fling profile measured p90/p99 0.09/0.26 ms at 3 versus
|
||||
# 0.15/0.42 ms at "s"; the 1.9 MB saving is not worth that frame cost.
|
||||
opt-level = 3
|
||||
|
||||
[profile.android-dev]
|
||||
inherits = "dev"
|
||||
panic = "abort"
|
||||
|
||||
# Full DWARF in each renderer-linked test binary writes tens of gigabytes.
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.test]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[[test]]
|
||||
name = "catch_a_fling"
|
||||
required-features = ["fixture"]
|
||||
|
||||
[[test]]
|
||||
name = "fence_fling"
|
||||
required-features = ["fixture"]
|
||||
|
||||
[[test]]
|
||||
name = "gesture_cancel"
|
||||
required-features = ["fixture"]
|
||||
|
||||
[[test]]
|
||||
name = "input_log_roundtrip"
|
||||
required-features = ["fixture"]
|
||||
|
||||
[[test]]
|
||||
name = "phone_screen"
|
||||
required-features = ["fixture"]
|
||||
|
||||
[[test]]
|
||||
name = "top_edge"
|
||||
required-features = ["fixture"]
|
||||
@@ -0,0 +1,90 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
}
|
||||
|
||||
// The Rust side (this directory's Cargo.toml) is built separately with
|
||||
// `cargo ndk`, straight into src/main/jniLibs/ -- see the repo-root
|
||||
// AGENTS.md-style comment at the top of Cargo.toml for why this crate
|
||||
// stays outside the main Rust workspace, and RUST.md's I2 for the exact
|
||||
// build command.
|
||||
android {
|
||||
namespace = "dev.iris.android.demo"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "dev.iris.android.demo"
|
||||
// 29, not 26: `iris::android::view`'s touch handler dates each
|
||||
// sample with `MotionEvent.getEventTimeNanos` and
|
||||
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
|
||||
// method there is a hard crash on the first touch rather than a
|
||||
// degraded fling. Raised deliberately rather than guarded at
|
||||
// runtime: nothing this app is built for runs below 29, and an
|
||||
// untested fallback path is its own defect. `build-apk.sh`'s
|
||||
// `cargo ndk -P` is kept at the same number.
|
||||
minSdk = 29
|
||||
// 37, matching `compileSdk` and the Compose app in `app/` -- which
|
||||
// is the one part of this that is measured rather than reasoned:
|
||||
// that app targets 37 and its keyboard does push the transcript up
|
||||
// on Iris's phone, and this one targeted 34 and does not
|
||||
// (2026-09-07). The emulator here is API 36 and the push-up works
|
||||
// there at either target, so the target is the only difference the
|
||||
// two devices do not share.
|
||||
//
|
||||
// The mechanism, stated as the reading it is: below targetSdk 35
|
||||
// a window keeps the legacy behaviour, where `adjustResize` shrinks
|
||||
// the window for the IME and `getInsets(ime()).bottom` therefore
|
||||
// measures the overlap with an already-shrunk window -- zero, with
|
||||
// nothing left to push up. `MainActivity`'s
|
||||
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
|
||||
// 36 it still takes; Android 16 deprecated it and Android 17 is
|
||||
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
|
||||
// the app is handed the real overlap without relying on a
|
||||
// deprecated call. If the phone still reports `ime_bottom=0` with
|
||||
// a nonzero `dispatches` in the Diagnostics pane, this reading was
|
||||
// wrong and the `WindowInsetsAnimation.Callback` in
|
||||
// `MainActivity` is the other half to look at.
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
// A release build must be signed, and the key is per machine rather than per repo -- same
|
||||
// reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone
|
||||
// recognises the app by, and a secret never lives in a checkout (the mount is shared with an
|
||||
// untrusted VM). `build-apk.sh` generates this key once and points at it through the
|
||||
// environment; without it a release build here is unsigned, which is fine for everything
|
||||
// except installing.
|
||||
def keystore = System.getenv("AI_APP_KEYSTORE")
|
||||
signingConfigs {
|
||||
if (keystore != null) {
|
||||
release {
|
||||
storeFile = file(keystore)
|
||||
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
|
||||
keyAlias = "ai-app"
|
||||
keyPassword = storePassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
}
|
||||
// P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so`
|
||||
// itself is built separately with `cargo ndk --release --features "transcript-screen
|
||||
// force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) --
|
||||
// Gradle here only packages and signs whatever is already there, the same division as the
|
||||
// debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it
|
||||
// installable beside a debug build of the tabs demo rather than replacing it.
|
||||
release {
|
||||
applicationIdSuffix ".bench"
|
||||
if (keystore != null) {
|
||||
signingConfig = signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Only needed by the transcript-screen feature (RUST.md's I5),
|
||||
which talks to a real ai-server; the plain tabs demo (I2/I4) makes
|
||||
no network call and never noticed this was missing. Absent,
|
||||
UreqTransport::new's connect failed with EPERM (Operation not
|
||||
permitted), not the ECONNREFUSED/ENETUNREACH a firewall or a dead
|
||||
server would give: a seccomp-level socket denial reads nothing
|
||||
like a network problem, which is what made it worth a comment. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="iris android-view demo"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- The enrollment link Dev Updater's Enroll button opens
|
||||
(what `ai-server` mints), the same one the Compose app
|
||||
in `app/` registers: which app answers it is the phone
|
||||
owner's choice at the moment of the tap, and both being
|
||||
offered is the intended behaviour rather than a clash.
|
||||
BROWSABLE so a link tapped in another app reaches here,
|
||||
and `android:host` so this app is not offered for every
|
||||
aiapp:// URI a future route invents. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="aiapp" android:host="enroll" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data android:name="android.app.lib_name" android:value="ai_app" />
|
||||
</activity>
|
||||
|
||||
<!-- This app's own recent log, for Dev Updater to read on the
|
||||
phone. Iris runs these builds with no adb, and Android
|
||||
forbids one app reading another's logcat, so this is the
|
||||
only way a log::info! here reaches her. The shape is Dev
|
||||
Updater's contract (its README.md, "An app's own log"), not
|
||||
something invented for this app.
|
||||
|
||||
The authority carries ${applicationId}, so the bench package
|
||||
and the ordinary one each get their own and neither can read
|
||||
the other's log. Exported, because the whole point is
|
||||
another app reading it, and guarded by a permission Dev
|
||||
Updater declares at protectionLevel="normal" (a signature
|
||||
permission is not available: the two apps are signed with
|
||||
different locally generated keys). Read-only: insert,
|
||||
update and delete throw. -->
|
||||
<provider
|
||||
android:name=".DevLogProvider"
|
||||
android:authorities="${applicationId}.devlog"
|
||||
android:exported="true"
|
||||
android:readPermission="dev.updater.permission.READ_DEVLOG" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,125 @@
|
||||
package dev.iris.android.demo;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.content.UriMatcher;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
|
||||
/** Read-only Dev Updater log provider; its URI and column schema are an external contract. */
|
||||
public final class DevLogProvider extends ContentProvider {
|
||||
static {
|
||||
// A provider can start the process without creating MainActivity.
|
||||
System.loadLibrary("ai_app");
|
||||
}
|
||||
|
||||
private static final int FIELDS_PER_LINE = 5;
|
||||
|
||||
private static final String[] LINE_COLUMNS = {"seq", "t_ms", "level", "target", "message"};
|
||||
private static final String[] STATUS_COLUMNS = {"held", "dropped", "newest_seq"};
|
||||
|
||||
private static final int LINES = 1;
|
||||
private static final int STATUS = 2;
|
||||
|
||||
private UriMatcher matcher;
|
||||
|
||||
private static native String[] nativeLinesSince(long since);
|
||||
|
||||
private static native String[] nativeStatus();
|
||||
|
||||
// The provider may be the process's only component, so it must supply
|
||||
// the files directory normally initialized by MainActivity.
|
||||
private static native void nativeReady(String authority, String filesDir);
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
String authority = getContext().getPackageName() + ".devlog";
|
||||
matcher = new UriMatcher(UriMatcher.NO_MATCH);
|
||||
matcher.addURI(authority, "lines", LINES);
|
||||
matcher.addURI(authority, "status", STATUS);
|
||||
nativeReady(authority, getContext().getFilesDir().getAbsolutePath());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor query(
|
||||
Uri uri,
|
||||
String[] projection,
|
||||
String selection,
|
||||
String[] selectionArgs,
|
||||
String sortOrder) {
|
||||
switch (matcher.match(uri)) {
|
||||
case LINES:
|
||||
return lines(sinceOf(uri));
|
||||
case STATUS:
|
||||
return status();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static long sinceOf(Uri uri) {
|
||||
String since = uri.getQueryParameter("since");
|
||||
if (since == null) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(since);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static Cursor lines(long since) {
|
||||
String[] fields = nativeLinesSince(since);
|
||||
if (fields == null) {
|
||||
return null;
|
||||
}
|
||||
MatrixCursor cursor = new MatrixCursor(LINE_COLUMNS, fields.length / FIELDS_PER_LINE);
|
||||
for (int at = 0; at + FIELDS_PER_LINE <= fields.length; at += FIELDS_PER_LINE) {
|
||||
cursor.addRow(
|
||||
new Object[] {
|
||||
Long.parseLong(fields[at]),
|
||||
Long.parseLong(fields[at + 1]),
|
||||
fields[at + 2],
|
||||
fields[at + 3],
|
||||
fields[at + 4],
|
||||
});
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
private static Cursor status() {
|
||||
String[] fields = nativeStatus();
|
||||
if (fields == null || fields.length != STATUS_COLUMNS.length) {
|
||||
return null;
|
||||
}
|
||||
MatrixCursor cursor = new MatrixCursor(STATUS_COLUMNS, 1);
|
||||
cursor.addRow(
|
||||
new Object[] {
|
||||
Long.parseLong(fields[0]), Long.parseLong(fields[1]), Long.parseLong(fields[2]),
|
||||
});
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException("this app's log is read-only");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("this app's log is read-only");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("this app's log is read-only");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dev.iris.android.demo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.view.Gravity;
|
||||
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, int imeVisible);
|
||||
|
||||
native void unregisterInsetsNative(long peer);
|
||||
|
||||
public IrisView(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
void applyWindowInsets(
|
||||
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
|
||||
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
unregisterInsetsNative(mViewPeer);
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
// This path must not depend on the renderer that failed to initialize.
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package dev.iris.android.demo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.WindowInsetsAnimation;
|
||||
import android.widget.FrameLayout;
|
||||
import java.util.List;
|
||||
|
||||
public final class MainActivity extends Activity {
|
||||
static {
|
||||
System.loadLibrary("ai_app");
|
||||
}
|
||||
|
||||
private static native void nativeSetFilesDir(String path);
|
||||
|
||||
private static native void nativeEnroll(String uri);
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
nativeSetFilesDir(getFilesDir().getAbsolutePath());
|
||||
handleEnrollmentIntent(getIntent());
|
||||
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();
|
||||
|
||||
// Edge-to-edge makes IME-only changes produce fresh inset dispatches.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getWindow().setDecorFitsSystemWindows(false);
|
||||
}
|
||||
|
||||
// Static dispatch supplies settled insets; the animation callback
|
||||
// supplies intermediate IME heights. An interrupted animation may
|
||||
// omit its final progress frame, so onEnd re-reads the root insets.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
|
||||
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||
@Override
|
||||
public WindowInsets onProgress(
|
||||
WindowInsets insets, List<WindowInsetsAnimation> running) {
|
||||
sendInsets(view, insets);
|
||||
return insets;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnd(WindowInsetsAnimation animation) {
|
||||
WindowInsets settled = view.getRootWindowInsets();
|
||||
if (settled != null) {
|
||||
sendInsets(view, settled);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||
sendInsets((IrisView) v, insets);
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
// Keep getIntent() consistent with the enrollment being handled.
|
||||
setIntent(intent);
|
||||
handleEnrollmentIntent(intent);
|
||||
}
|
||||
|
||||
private static void handleEnrollmentIntent(Intent intent) {
|
||||
if (intent == null) {
|
||||
return;
|
||||
}
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
nativeEnroll(data.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendInsets(IrisView view, WindowInsets insets) {
|
||||
int left = insets.getSystemWindowInsetLeft();
|
||||
int top = insets.getSystemWindowInsetTop();
|
||||
int right = insets.getSystemWindowInsetRight();
|
||||
int bottom = insets.getSystemWindowInsetBottom();
|
||||
// Visibility and height disagree during IME animation, so neither
|
||||
// can be inferred from the other.
|
||||
int imeBottom = 0;
|
||||
int imeVisible = 0;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
|
||||
}
|
||||
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
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. The only local change is `protected`,
|
||||
// allowing IrisView to forward insets through this native peer.
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.4.0" apply false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "iris-android-demo"
|
||||
include(":app")
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/bin/sh
|
||||
# Builds the Android app end to end: the cdylib (cargo ndk from this
|
||||
# directory, straight into android-project/app/src/main/jniLibs/) then the
|
||||
# APK (Gradle, from android-project/). Written to stop re-typing
|
||||
# the same incantation by hand every time (ANDROID_HOME/NDK exports, the
|
||||
# cargo ndk invocation, the keystore env for a release build, apksigner/
|
||||
# aapt2 verification) -- see docs/RUST.md's P0 box. Same shape as `app/
|
||||
# build-apk.sh` (the Compose app's own build script) and `app/
|
||||
# iris-scroll.sh` (no coordinates, set -eu, exit 0 on success).
|
||||
#
|
||||
# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"]
|
||||
# debug/release default to debug (matches this-machine-android's "the
|
||||
# emulator stays on debug" rule -- pass `release` explicitly for a phone
|
||||
# build). --abi defaults to arm64-v8a (a phone/real device); pass
|
||||
# x86_64 for this checkout's own AVD. --features defaults to
|
||||
# "transcript-screen bench" -- deliberately *without* `force-gles`, and
|
||||
# nothing should add it back for the emulator's sake.
|
||||
#
|
||||
# **The emulator does not need a GLES build, because it has no hardware
|
||||
# Vulkan to be steered away from** (docs/RUST.md, "What the emulator
|
||||
# gives a GPU app", 2026-09-08): its guest's only Vulkan is SwiftShader
|
||||
# in software, its GLES is the host's real GPU through virgl, and iris's
|
||||
# own runtime fallback -- `Backends::PRIMARY`, no adapter, rebuild on
|
||||
# `Backends::GL` -- takes an ordinary build there by itself. So the
|
||||
# emulator and the phone run the *same binary* and differ only in what
|
||||
# that binary finds, which is the whole point: a build flag that changed
|
||||
# the backend would mean the thing measured here is not the thing
|
||||
# shipped.
|
||||
#
|
||||
# `force-gles` (`iris/Cargo.toml`'s own doc) pins the backend at compile
|
||||
# time for a backend-isolation measurement (RUST.md's I5, "Where iris's
|
||||
# frame time goes"), and the desktop is the better place to run it now
|
||||
# (`run-headless.sh ... --features iris/force-gles`). It was never meant
|
||||
# to reach a real device, but this script's old default put it in every
|
||||
# arm64 build regardless, so the P0 bench APK delivered to Iris's phone
|
||||
# forced GLES there too -- the named hypothesis in RUST.md's P0 box
|
||||
# ("iris bench crash on the phone, 2026-09-06"). Never pass it for a
|
||||
# build meant for a phone.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BUILD_TYPE="debug"
|
||||
ABI="arm64-v8a"
|
||||
FEATURES="transcript-screen bench"
|
||||
case "${1:-}" in
|
||||
debug|release) BUILD_TYPE="$1"; shift ;;
|
||||
esac
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--abi) ABI="$2"; shift 2 ;;
|
||||
--features) FEATURES="$2"; shift 2 ;;
|
||||
*) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SDK_ROOT="$HOME/Android/Sdk"
|
||||
export ANDROID_HOME="$SDK_ROOT"
|
||||
export ANDROID_SDK_ROOT="$SDK_ROOT"
|
||||
NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1)
|
||||
if [ -z "$NDK_DIR" ]; then
|
||||
echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2
|
||||
exit 1
|
||||
fi
|
||||
export ANDROID_NDK_HOME="$NDK_DIR"
|
||||
|
||||
# Only the ABI asked for goes into the APK. cargo ndk adds its output beside
|
||||
# whatever earlier builds left here, and Gradle packages every directory it
|
||||
# finds -- a debug x86_64 emulator build left behind made an arm64 "release"
|
||||
# 339 MB on 2026-09-06.
|
||||
rm -rf android-project/app/src/main/jniLibs
|
||||
# ...and Gradle's own copy of them, which `rm -rf jniLibs` does not reach.
|
||||
# `mergeReleaseNativeLibs` is *up to date* against its cached inputs, so a
|
||||
# build that switches ABI packages the previous ABI: an `--abi x86_64`
|
||||
# release APK containing `lib/arm64-v8a/libmain.so` installed fine and
|
||||
# aborted at startup with `Could not get adapter!: NotFound {
|
||||
# active_backends: VULKAN }` under libndk_translation -- which reads
|
||||
# exactly like the phone's own Vulkan problem and is nothing of the kind.
|
||||
# Scoped to the merge task's directory rather than all of `app/build`, so
|
||||
# an ABI change costs the native merge and not the whole Gradle build.
|
||||
rm -rf android-project/app/build/intermediates/merged_native_libs \
|
||||
android-project/app/build/intermediates/stripped_native_libs \
|
||||
android-project/app/build/intermediates/merged_jni_libs
|
||||
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
|
||||
--profile android-release --no-default-features --features "$FEATURES"
|
||||
else
|
||||
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
|
||||
--profile android-dev --no-default-features --features "$FEATURES"
|
||||
fi
|
||||
|
||||
GRADLE_TASK="assembleDebug"
|
||||
APK_DIR="android-project/app/build/outputs/apk/debug"
|
||||
APK_NAME="app-debug.apk"
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
GRADLE_TASK="assembleRelease"
|
||||
APK_DIR="android-project/app/build/outputs/apk/release"
|
||||
APK_NAME="app-release.apk"
|
||||
# Same key `app/build-apk.sh` (the Compose app) generates once under
|
||||
# ~/.config/ai-app/release.jks -- see AGENTS.md's "Checking your work".
|
||||
export AI_APP_KEYSTORE="$HOME/.config/ai-app/release.jks"
|
||||
if [ ! -f "$AI_APP_KEYSTORE" ]; then
|
||||
echo "build-apk.sh: no release key at $AI_APP_KEYSTORE -- run app/build-apk.sh once first" >&2
|
||||
exit 1
|
||||
fi
|
||||
export AI_APP_KEYSTORE_PASSWORD
|
||||
AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password")
|
||||
fi
|
||||
|
||||
(cd android-project && gradle ":app:$GRADLE_TASK" --console=plain)
|
||||
|
||||
APK_PATH="$(pwd)/$APK_DIR/$APK_NAME"
|
||||
BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1)
|
||||
echo "--- aapt2 dump badging ---"
|
||||
"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
echo "--- apksigner verify ---"
|
||||
"${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH"
|
||||
fi
|
||||
echo "$APK_PATH"
|
||||
@@ -0,0 +1,102 @@
|
||||
use iris::prelude::*;
|
||||
use winit::{dpi::PhysicalSize, window::WindowAttributes};
|
||||
|
||||
fn ime_argv() -> Option<f32> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--ime" {
|
||||
return args.next()?.parse().ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn message_argv() -> Option<String> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--message" {
|
||||
return Some(args.next()?.replace("\\n", "\n"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn typed_argv() -> Option<String> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--typed" {
|
||||
return Some(args.next()?.replace("\\n", "\n"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn main() {
|
||||
DefaultApp::<Client>::run();
|
||||
}
|
||||
|
||||
#[derive(DefaultUiState)]
|
||||
pub struct Client {
|
||||
ui_state: DefaultUiState,
|
||||
#[allow(dead_code)]
|
||||
screen: Option<ai_app::ui::TranscriptScreen>,
|
||||
}
|
||||
|
||||
impl DefaultAppState for Client {
|
||||
fn window_attributes() -> WindowAttributes {
|
||||
WindowAttributes::default()
|
||||
.with_title("iris transcript (bench fixture)")
|
||||
.with_inner_size(PhysicalSize::new(
|
||||
ai_app::ui::fixture::PHONE_WIDTH,
|
||||
ai_app::ui::fixture::PHONE_HEIGHT,
|
||||
))
|
||||
}
|
||||
|
||||
fn new(
|
||||
mut ui_state: DefaultUiState,
|
||||
rsc: &mut DefaultRsc<Self>,
|
||||
_: Proxy<Self::Event>,
|
||||
) -> Self {
|
||||
let screen = match ai_app::ui::fixture::open(rsc, &mut ui_state) {
|
||||
Ok(opened) => {
|
||||
if let Some(message) = message_argv() {
|
||||
opened.screen.composer.field.edit(rsc).set(&message);
|
||||
}
|
||||
if let Some(text) = typed_argv() {
|
||||
let field = opened.screen.composer.field;
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
for ch in text.chars() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
ctx.update(move |state: &mut Client, rsc| {
|
||||
state.set_focus(Some(field));
|
||||
let end = rsc[field].text().len();
|
||||
let mut edit = field.edit(rsc);
|
||||
if edit.text.caret().is_none() {
|
||||
edit.set_cursor_byte(end);
|
||||
}
|
||||
edit.insert(&ch.to_string());
|
||||
});
|
||||
redraw.request_redraw();
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(inset) = ime_argv() {
|
||||
opened.screen.composer.set_bottom_inset(rsc, inset);
|
||||
}
|
||||
Some(opened.screen)
|
||||
}
|
||||
Err(message) => {
|
||||
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
|
||||
.color(PaintId::WHITE)
|
||||
.wrap(true)
|
||||
.pad(dp(16))
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
ui_state.set_root(rsc, text);
|
||||
None
|
||||
}
|
||||
};
|
||||
Self { ui_state, screen }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use ai_app::client::QuestionOption;
|
||||
use ai_app::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||
use iris::prelude::*;
|
||||
|
||||
fn main() {
|
||||
DefaultApp::<Client>::run();
|
||||
}
|
||||
|
||||
#[derive(DefaultUiState)]
|
||||
pub struct Client {
|
||||
ui_state: DefaultUiState,
|
||||
#[allow(dead_code)]
|
||||
screen: ai_app::ui::TranscriptScreen,
|
||||
}
|
||||
|
||||
fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
|
||||
FoldedRow::Single(if from_user {
|
||||
TranscriptItem::UserMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
TranscriptItem::AssistantMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
settled: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem {
|
||||
tool_call_in("run1", id, tool, input, result)
|
||||
}
|
||||
|
||||
fn tool_call_in(
|
||||
run: &str,
|
||||
id: &str,
|
||||
tool: &str,
|
||||
input: &str,
|
||||
result: Option<(&str, bool)>,
|
||||
) -> TranscriptItem {
|
||||
TranscriptItem::ToolRun {
|
||||
seq: 3,
|
||||
id: id.into(),
|
||||
run_id: run.into(),
|
||||
tool: tool.into(),
|
||||
input: input.into(),
|
||||
output: result.map(|(out, _)| out.to_string()).unwrap_or_default(),
|
||||
done: result.is_some(),
|
||||
failed: result.is_some_and(|(_, failed)| failed),
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
|
||||
let mut call = tool_call_in("run2", id, tool, input, None);
|
||||
if let TranscriptItem::ToolRun { asks, .. } = &mut call {
|
||||
asks.push(QuestionCard {
|
||||
seq: 9,
|
||||
id: format!("{id}-q"),
|
||||
prompt: "Allow this command?".into(),
|
||||
header: None,
|
||||
options: vec![
|
||||
QuestionOption {
|
||||
label: "Allow".into(),
|
||||
description: None,
|
||||
preview: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Deny".into(),
|
||||
description: None,
|
||||
preview: None,
|
||||
},
|
||||
],
|
||||
multi_select: false,
|
||||
answers: Vec::new(),
|
||||
});
|
||||
}
|
||||
call
|
||||
}
|
||||
|
||||
fn long_output() -> String {
|
||||
(0..200)
|
||||
.map(|i| format!("test ai_app::ui::case_{i} ... ok"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn synthetic_rows() -> Vec<FoldedRow> {
|
||||
vec and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
|
||||
),
|
||||
FoldedRow::Tools(vec![
|
||||
tool_call(
|
||||
"t1",
|
||||
"Read",
|
||||
r#"{"file_path": "src/main.rs"}"#,
|
||||
Some(("fn main() {}\n", false)),
|
||||
),
|
||||
tool_call(
|
||||
"t2",
|
||||
"Bash",
|
||||
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
|
||||
Some((
|
||||
"error: could not compile `iris`\nCaused by: linker not found",
|
||||
true,
|
||||
)),
|
||||
),
|
||||
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
|
||||
]),
|
||||
FoldedRow::Single(tool_call(
|
||||
"t5",
|
||||
"Bash",
|
||||
r#"{"command": "cargo test -p transcript-ui -- --nocapture"}"#,
|
||||
Some((&long_output(), false)),
|
||||
)),
|
||||
msg(6, true, "Looks good, thanks!"),
|
||||
msg(7, false, BLOCK_SAMPLER),
|
||||
]
|
||||
}
|
||||
|
||||
const BLOCK_SAMPLER: &str = "\
|
||||
## What changed
|
||||
|
||||
Iris **fold** render measure session window anchor context transcript \
|
||||
iris measure iris scroll call transcript layout *cursor* context, and a \
|
||||
[bench](https://example.com/bench) link.
|
||||
|
||||
```rust
|
||||
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||
let mut out = items;
|
||||
out.push(Item::new(seq));
|
||||
out
|
||||
}
|
||||
```
|
||||
|
||||
| column | value |
|
||||
|---|---|
|
||||
| a | measure place draw tool call token context window anchor |
|
||||
|
||||
- one bullet
|
||||
- another, with `inline code`
|
||||
- nested one level
|
||||
1. first numbered
|
||||
2. second numbered
|
||||
|
||||
> A quoted line, to show the bar and the indent.
|
||||
";
|
||||
|
||||
impl DefaultAppState for Client {
|
||||
fn new(
|
||||
mut ui_state: DefaultUiState,
|
||||
rsc: &mut DefaultRsc<Self>,
|
||||
_: Proxy<Self::Event>,
|
||||
) -> Self {
|
||||
let screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows());
|
||||
screen.push_row(
|
||||
rsc,
|
||||
&FoldedRow::Single(TranscriptItem::CommandRow {
|
||||
seq: 8,
|
||||
text: "clear".into(),
|
||||
}),
|
||||
);
|
||||
screen.push_row(
|
||||
rsc,
|
||||
&FoldedRow::Tools(vec![
|
||||
tool_call_in(
|
||||
"run2",
|
||||
"t6",
|
||||
"Read",
|
||||
r#"{"file_path": "docs/RUST.md"}"#,
|
||||
Some(("# Moving the app to Rust\n", false)),
|
||||
),
|
||||
tool_call_in(
|
||||
"run2",
|
||||
"t7",
|
||||
"Bash",
|
||||
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
|
||||
Some(("error: unused variable `x`", true)),
|
||||
),
|
||||
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
|
||||
asking(
|
||||
"t9",
|
||||
"Bash",
|
||||
r#"{"command": "rm -rf target", "timeout": 120000, "description": "Clear the build"}"#,
|
||||
),
|
||||
]),
|
||||
);
|
||||
screen.set_session_working(rsc, true);
|
||||
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
|
||||
assert!(
|
||||
screen.expand_tail_tools(rsc, true),
|
||||
"the newest row must be the tool run this flag is about"
|
||||
);
|
||||
}
|
||||
Self { ui_state, screen }
|
||||
}
|
||||
}
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/bin/sh
|
||||
# Installs and runs the iris `bench` build on this checkout's own emulator
|
||||
# (per this-machine-android's per-checkout-AVD rule; `emu serial` picks it)
|
||||
# and prints the report -- the iris half of `app/transcript-bench.sh`'s
|
||||
# job. No coordinates: the button is found by its accessibility label
|
||||
# through `ui-trace`, per AGENTS.md's "Driving the UI".
|
||||
#
|
||||
# Usage: ./run-bench.sh [--apk PATH]
|
||||
# Defaults to this checkout's own release APK
|
||||
# (android-project/app/build/outputs/apk/release/app-release.apk) if it
|
||||
# exists, else the
|
||||
# debug one -- build one first with ./build-apk.sh.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APK=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--apk) APK="$2"; shift 2 ;;
|
||||
*) echo "run-bench.sh: unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
if [ -z "$APK" ]; then
|
||||
if [ -f android-project/app/build/outputs/apk/release/app-release.apk ]; then
|
||||
APK=android-project/app/build/outputs/apk/release/app-release.apk
|
||||
else
|
||||
APK=android-project/app/build/outputs/apk/debug/app-debug.apk
|
||||
fi
|
||||
fi
|
||||
if [ ! -f "$APK" ]; then
|
||||
echo "run-bench.sh: no APK at $APK -- run ./build-apk.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERIAL=$(emu serial)
|
||||
PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
|
||||
if [ -z "$PKG" ]; then
|
||||
BUILD_TOOLS=$(ls -d "$HOME"/Android/Sdk/build-tools/*/ | sort -V | tail -1)
|
||||
PKG=$("${BUILD_TOOLS}aapt2" dump badging "$APK" | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
|
||||
fi
|
||||
|
||||
echo "run-bench.sh: installing $APK ($PKG) on $SERIAL"
|
||||
adb -s "$SERIAL" install -r "$APK" >/dev/null
|
||||
adb -s "$SERIAL" shell am force-stop "$PKG"
|
||||
adb -s "$SERIAL" logcat -c
|
||||
adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/dev/null
|
||||
|
||||
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
|
||||
|
||||
# Which adapter drew, before any number is printed. The emulator is a GLES
|
||||
# machine -- its guest has no hardware Vulkan (docs/RUST.md, "What the
|
||||
# emulator gives a GPU app") -- so iris's runtime fallback lands on `Gl`,
|
||||
# and `Gl (... virgl ...)` is the host's real GPU while `Gl (...
|
||||
# SwiftShader ...)` is the CPU. Those two produce frame times an order of
|
||||
# magnitude apart and are otherwise indistinguishable in this report, so
|
||||
# the line is printed rather than left in logcat for somebody to think of.
|
||||
ADAPTER=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null \
|
||||
| sed -n 's/.*\(iris renderer: .*\)/\1/p' | tail -1)
|
||||
if [ -n "$ADAPTER" ]; then
|
||||
echo "run-bench.sh: $ADAPTER"
|
||||
else
|
||||
echo "run-bench.sh: no 'iris renderer:' line in logcat -- cannot say what drew this run" >&2
|
||||
fi
|
||||
|
||||
# Poll for the report line rather than a fixed sleep -- the run itself is
|
||||
# a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
|
||||
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
|
||||
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
|
||||
# a longer script than v1's swipe-loop-only run.
|
||||
# The report's own first line, not the bare "iris bench report:" prefix:
|
||||
# `copy_report` logs that prefix too ("nothing to copy -- run the benchmark
|
||||
# first", which the app emits at startup), so polling for the prefix
|
||||
# returned instantly and the script printed a report that was never run.
|
||||
REPORT_LINE="iris bench report: iris bench report"
|
||||
i=0
|
||||
while [ "$i" -lt 260 ]; do
|
||||
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
|
||||
if [ -n "$LINE" ]; then
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$LINE" ]; then
|
||||
echo "run-bench.sh: no report after 260s -- check logcat by hand" >&2
|
||||
exit 1
|
||||
fi
|
||||
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
|
||||
# phases, four lines each) on top of the frames/bench sections v1 had.
|
||||
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "$REPORT_LINE"
|
||||
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
components = ["clippy", "rustfmt"]
|
||||
targets = ["aarch64-linux-android", "x86_64-linux-android"]
|
||||
@@ -0,0 +1,135 @@
|
||||
//! The platform half of this app's logging: what
|
||||
//! `crate::client::log_ring` needs that only Android can supply, which is
|
||||
//! `android_logger` as the logger to forward to and nothing else.
|
||||
|
||||
use crate::client::log_ring::{self, LogRing};
|
||||
|
||||
/// Installs the ring in front of `android_logger`, so `logcat` still sees
|
||||
/// exactly what it saw before and the ring sees it too.
|
||||
///
|
||||
/// Called once, from `JNI_OnLoad`. A second call is refused by `log`
|
||||
/// itself; the message says which caller, since two initialisation paths
|
||||
/// is a programmer error rather than something to recover from.
|
||||
pub fn install(max_level: log::LevelFilter) {
|
||||
let inner = android_logger::AndroidLogger::new(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(max_level)
|
||||
.with_tag("iris-android-app"),
|
||||
);
|
||||
if log_ring::install_process_logger(
|
||||
Box::new(inner),
|
||||
max_level,
|
||||
iris::diagnostics::trace_enabled,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
// Not a panic: a logger already installed means logging works,
|
||||
// just without the ring, and taking the app down over a
|
||||
// diagnostic would be worse than the diagnostic being missing.
|
||||
// The line goes through whatever logger did win.
|
||||
log::warn!("iris app log: a logger was already installed, so there is no ring");
|
||||
}
|
||||
install_panic_hook();
|
||||
}
|
||||
|
||||
pub fn ring() -> &'static LogRing {
|
||||
log_ring::process_ring()
|
||||
}
|
||||
|
||||
#[cfg(feature = "bench")]
|
||||
pub fn diagnostics_line() -> String {
|
||||
let where_to_read = match crate::android::devlog::authority() {
|
||||
Some(authority) => format!("devlog provider: content://{authority}"),
|
||||
// Not "off": Android creates a provider lazily, so this is what
|
||||
// "nobody has asked for it yet" looks like, and it is a different
|
||||
// thing from a build that does not have one.
|
||||
None => "devlog provider: declared, not created yet".to_string(),
|
||||
};
|
||||
format!("{}\n{where_to_read}", ring().summary())
|
||||
}
|
||||
|
||||
/// Where the panic hook leaves its report, under the app's private
|
||||
/// directory. Read back and dropped by [`set_crash_dir`] on the next
|
||||
/// start.
|
||||
const CRASH_FILE: &str = "last-panic.txt";
|
||||
|
||||
/// How many of the dying run's own log lines the panic hook saves with
|
||||
/// the panic, and [`set_crash_dir`] replays.
|
||||
///
|
||||
/// The panic's message and location say *what* broke; these say what the
|
||||
/// app was doing on the way there, which is the half that is otherwise
|
||||
/// unrecoverable -- the ring is memory only, so an abort takes every line
|
||||
/// before the panic with it. Bounded rather than the whole ring because
|
||||
/// this is written by a hook on a process that is about to die, and
|
||||
/// because the replay pushes each line into the new run's ring, where an
|
||||
/// unbounded paste would evict the run that is actually being watched.
|
||||
const CRASH_CONTEXT_LINES: usize = 80;
|
||||
|
||||
const PREVIOUS_RUN_TARGET: &str = "previous_run";
|
||||
|
||||
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Copies aborting panics into the device-readable log ring.
|
||||
fn install_panic_hook() {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let where_at = match info.location() {
|
||||
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
|
||||
None => "an unknown location".to_string(),
|
||||
};
|
||||
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
|
||||
let line = format!("iris panic at {where_at}: {message}");
|
||||
log::error!("{line}");
|
||||
if let Some(path) = CRASH_PATH.get() {
|
||||
// The panic line first, then what the app was doing before
|
||||
// it: one file, split again on that first newline by
|
||||
// `set_crash_dir`.
|
||||
let context = ring()
|
||||
.try_tail_text(CRASH_CONTEXT_LINES)
|
||||
.unwrap_or_else(|| {
|
||||
"(the log ring was locked as this run died; no context)".to_string()
|
||||
});
|
||||
// Best effort by design: a panic is already the failure, and
|
||||
// failing to record it must not become a second one.
|
||||
let _ = std::fs::write(path, format!("{line}\n{context}"));
|
||||
}
|
||||
previous(info);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Tells the panic hook where to leave its report, and replays the report
|
||||
/// a previous run left there into the ring before deleting it.
|
||||
///
|
||||
/// Called from **both** `MainActivity.nativeSetFilesDir` and
|
||||
/// `DevLogProvider.nativeReady` -- whichever of the two runs first in
|
||||
/// this process, since after a crash Dev Updater's query starts the
|
||||
/// process for the provider alone and no activity ever runs. Safe to call
|
||||
/// twice: the file is gone after the first, so the second finds nothing
|
||||
/// and says nothing. The panic itself is replayed at `error` level and
|
||||
/// says it is from the previous run, so a crash loop shows the reason it
|
||||
/// is looping in the Runtime tab of the run that is still up.
|
||||
pub fn set_crash_dir(dir: &std::path::Path) {
|
||||
let path = dir.join(CRASH_FILE);
|
||||
if let Ok(previous) = std::fs::read_to_string(&path) {
|
||||
// Delete before replaying rather than after: a replay that itself
|
||||
// panicked would otherwise leave the file to be replayed again on
|
||||
// every start, and a crash loop nothing can get out of is worse
|
||||
// than one report lost.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
replay_crash(&previous);
|
||||
}
|
||||
let _ = CRASH_PATH.set(path);
|
||||
}
|
||||
|
||||
/// Puts a previous run's report back in the ring: its context lines in
|
||||
/// the order they happened, then the panic itself.
|
||||
fn replay_crash(report: &str) {
|
||||
let (panic_line, context) = report.split_once('\n').unwrap_or((report, ""));
|
||||
for line in context.lines().filter(|line| !line.is_empty()) {
|
||||
ring().push(log::Level::Info, PREVIOUS_RUN_TARGET, line.to_string());
|
||||
}
|
||||
log::error!(
|
||||
"iris app log: the previous run died -- {}",
|
||||
panic_line.trim()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,881 @@
|
||||
use crate::android::bench_jni::PlatformHandle;
|
||||
use crate::client::transcript_fold::{TranscriptItem, fold_event};
|
||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||
use event_model::SeqEvent;
|
||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||
use iris::prelude::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
||||
const STREAM_SECONDS: u64 = 20;
|
||||
|
||||
const LEGACY_CYCLES: usize = 6;
|
||||
|
||||
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
|
||||
const FLING_COUNT: usize = 8;
|
||||
const FLING_SETTLE_CAP_MS: u64 = 3_000;
|
||||
const FLING_PAUSE_MS: u64 = 300;
|
||||
|
||||
const TYPE_TEXT: &str = "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!!!";
|
||||
const TYPE_CHAR_MS: u64 = 50;
|
||||
|
||||
const KEYBOARD_CYCLES: usize = 5;
|
||||
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
||||
|
||||
const POLL_MS: u64 = 16;
|
||||
|
||||
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
|
||||
|
||||
pub struct BenchClient {
|
||||
ui_state: AndroidUiState,
|
||||
content: WeakWidget<WidgetPtr>,
|
||||
report_display: WeakWidget<TextEdit>,
|
||||
top_bar: WeakWidget<WidgetPtr>,
|
||||
screen: Option<crate::ui::TranscriptScreen>,
|
||||
items: Vec<TranscriptItem>,
|
||||
stream_tail: Vec<SeqEvent>,
|
||||
platform: Option<Arc<PlatformHandle>>,
|
||||
last_report: Option<String>,
|
||||
running: bool,
|
||||
ime_state: Arc<Mutex<ImeState>>,
|
||||
keyboard_was_visible: bool,
|
||||
last_top_pad: f32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ImeState {
|
||||
visible: bool,
|
||||
shown_events: u32,
|
||||
hidden_events: u32,
|
||||
}
|
||||
|
||||
impl HasAndroidUiState for BenchClient {
|
||||
fn android_state(&self) -> &AndroidUiState {
|
||||
&self.ui_state
|
||||
}
|
||||
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||
&mut self.ui_state
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||
wtext(message.to_string())
|
||||
.color(PaintId::WHITE)
|
||||
.wrap(true)
|
||||
.pad(16)
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
fn process_cpu_ms() -> Option<u64> {
|
||||
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
|
||||
// initialises on success; on failure it is never read.
|
||||
unsafe {
|
||||
let mut usage: libc::rusage = std::mem::zeroed();
|
||||
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
|
||||
return None;
|
||||
}
|
||||
let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000;
|
||||
let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000;
|
||||
Some(user_ms + sys_ms)
|
||||
}
|
||||
}
|
||||
|
||||
fn peak_rss_kb() -> Option<u64> {
|
||||
std::fs::read_to_string("/proc/self/status")
|
||||
.ok()?
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("VmHWM:"))
|
||||
.and_then(|rest| rest.trim().strip_suffix("kB"))
|
||||
.and_then(|n| n.trim().parse().ok())
|
||||
}
|
||||
|
||||
fn battery_line(samples: &[i32]) -> String {
|
||||
if samples.is_empty() {
|
||||
return " battery current: unavailable on this device".to_string();
|
||||
}
|
||||
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
||||
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
|
||||
unreachable!("samples is non-empty, checked above");
|
||||
};
|
||||
format!(
|
||||
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
||||
samples.len()
|
||||
)
|
||||
}
|
||||
|
||||
impl AndroidAppState for BenchClient {
|
||||
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||
let content = WidgetPtr::new().add(rsc);
|
||||
let loading = placeholder(rsc, "Loading fixture...");
|
||||
content(rsc).set(loading);
|
||||
|
||||
let report_display = wtext("")
|
||||
.editable(EditMode::MultiLine)
|
||||
.text_align(Align::LEFT)
|
||||
.wrap(true)
|
||||
.size(14)
|
||||
.color(PaintId::WHITE)
|
||||
.attr::<Selectable>(())
|
||||
.label("Benchmark report")
|
||||
.add(rsc);
|
||||
|
||||
let top_bar = WidgetPtr::new().add(rsc);
|
||||
let controls = bench_controls(rsc, 0.0);
|
||||
top_bar(rsc).set(controls);
|
||||
let tree = (
|
||||
top_bar,
|
||||
report_display
|
||||
.pad(dp(8))
|
||||
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
|
||||
content.height(rest(1)),
|
||||
)
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
ui_state.set_root(rsc, tree);
|
||||
|
||||
let font = rsc.ui.text.font_diagnostics();
|
||||
log::info!(
|
||||
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
||||
bold={:?} italic={:?} mono={:?}, icons={:?}",
|
||||
font.families_found,
|
||||
font.default_family,
|
||||
font.default_mono_family,
|
||||
font.regular_resolved,
|
||||
font.bold_resolved,
|
||||
font.italic_resolved,
|
||||
font.mono_resolved,
|
||||
font.icon_family,
|
||||
);
|
||||
|
||||
let mut client = Self {
|
||||
ui_state,
|
||||
content,
|
||||
report_display,
|
||||
top_bar,
|
||||
screen: None,
|
||||
items: Vec::new(),
|
||||
stream_tail: Vec::new(),
|
||||
platform: None,
|
||||
last_report: None,
|
||||
running: false,
|
||||
ime_state: Arc::new(Mutex::new(ImeState::default())),
|
||||
keyboard_was_visible: false,
|
||||
last_top_pad: 0.0,
|
||||
};
|
||||
|
||||
match crate::ui::fixture::build_screen(rsc) {
|
||||
Ok((opened, tree)) => {
|
||||
client.items = opened.items;
|
||||
client.stream_tail = opened.stream_tail;
|
||||
(client.content)(rsc).set(tree);
|
||||
client.screen = Some(opened.screen);
|
||||
}
|
||||
Err(message) => {
|
||||
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
||||
}
|
||||
}
|
||||
client
|
||||
}
|
||||
|
||||
fn platform_ready(&mut self, _rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {
|
||||
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
|
||||
}
|
||||
|
||||
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn on_insets_changed(
|
||||
&mut self,
|
||||
rsc: &mut AndroidRsc<Self>,
|
||||
insets: iris::android::WindowInsets,
|
||||
) {
|
||||
if insets.top != self.last_top_pad {
|
||||
self.last_top_pad = insets.top;
|
||||
let controls = bench_controls(rsc, insets.top);
|
||||
(self.top_bar)(rsc).set(controls);
|
||||
}
|
||||
|
||||
if let Some(screen) = &self.screen {
|
||||
screen
|
||||
.composer
|
||||
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
||||
}
|
||||
|
||||
// The platform's own answer, not `ime_bottom > 0.0` -- see
|
||||
// `iris::android::WindowInsets::ime_bottom`. The height is still
|
||||
// climbing while the keyboard slides in, so a frame or two of a
|
||||
// real opening reads as "closed" when the boolean is inferred from
|
||||
// it, and `shown_events`/`hidden_events` below count transitions.
|
||||
let ime_visible = insets.ime_visible;
|
||||
|
||||
let mut ime = self.ime_state.lock().unwrap();
|
||||
if ime_visible && !ime.visible {
|
||||
ime.shown_events += 1;
|
||||
}
|
||||
if !ime_visible && ime.visible {
|
||||
ime.hidden_events += 1;
|
||||
}
|
||||
ime.visible = ime_visible;
|
||||
drop(ime);
|
||||
|
||||
if ime_visible && !self.keyboard_was_visible {
|
||||
self.keyboard_was_visible = true;
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
state.capture_keyboard_diagnostics(rsc);
|
||||
});
|
||||
redraw.request_redraw();
|
||||
});
|
||||
} else if !ime_visible {
|
||||
self.keyboard_was_visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
||||
|
||||
type Rsc = AndroidRsc<BenchClient>;
|
||||
|
||||
/// What a report says about the `iris::input`/`iris::frame` trace, from
|
||||
/// the flag read at the start of what is being reported and again at the
|
||||
/// end.
|
||||
///
|
||||
/// Three answers rather than two. Those lines are default-off and the
|
||||
/// switch that turns them on is on screen while a benchmark runs, so
|
||||
/// "somebody moved it half way through" is a state that actually happens
|
||||
/// -- and reported as either "on" or "off" it is a confident sentence
|
||||
/// about a log that only covers part of the run. The "on" wording also
|
||||
/// says what it costs, because a traced run fills the ring in seconds and
|
||||
/// a reader looking at a log with nothing else in it should know why.
|
||||
fn trace_line(at_start: bool, at_end: bool) -> String {
|
||||
match (at_start, at_end) {
|
||||
(true, true) => "input/frame trace: on (iris::input and iris::frame lines are in \
|
||||
the app log, and a traced run fills the ring in seconds)"
|
||||
.to_string(),
|
||||
(false, false) => "input/frame trace: off".to_string(),
|
||||
_ => "input/frame trace: switched during this run, so those lines cover only part \
|
||||
of it"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
|
||||
/// why it needs one at all. A dark neutral rather than pure black
|
||||
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
|
||||
/// instead of a hole in the background the buttons happen to float in.
|
||||
const HEADER_SURFACE: Srgba8 = Srgba8::new(28, 28, 34, 255);
|
||||
|
||||
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
|
||||
/// `on_insets_changed` has run once) -- folded in here, rather than
|
||||
/// exposing the unadded builder for a caller to `.pad()` itself, because
|
||||
/// naming that builder's type at each call site is more machinery than a
|
||||
/// top-of-screen padding number is worth.
|
||||
const HEADER_TEXT: f32 = 18.0;
|
||||
|
||||
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
|
||||
|
||||
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
let run_rect = rect(Srgba8::rgb(40, 70, 40))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||
ctx.state.start_benchmark(rsc);
|
||||
},
|
||||
)
|
||||
.label("Run benchmark");
|
||||
let run = (
|
||||
run_rect,
|
||||
wtext("Run benchmark")
|
||||
.size(HEADER_TEXT)
|
||||
.text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let copy_rect = rect(Srgba8::rgb(50, 50, 60))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||
ctx.state.copy_report(rsc);
|
||||
},
|
||||
)
|
||||
.label("Copy report");
|
||||
let copy = (
|
||||
copy_rect,
|
||||
wtext("Copy report")
|
||||
.size(HEADER_TEXT)
|
||||
.text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let diag_rect = rect(Srgba8::rgb(60, 45, 70))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||
ctx.state.show_diagnostics(rsc);
|
||||
},
|
||||
)
|
||||
.label("Diagnostics");
|
||||
let diagnostics = (
|
||||
diag_rect,
|
||||
wtext("Diagnostics")
|
||||
.size(HEADER_TEXT)
|
||||
.text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
// A switch rather than a button, so its own appearance says which
|
||||
// state it is in: the two `iris::input`/`iris::frame` targets are
|
||||
// default-off (`iris::diagnostics`'s module doc) because a 120Hz
|
||||
// session fills the 2000-line ring in seconds, so "is it on right
|
||||
// now" is the question somebody has while looking at a log that is
|
||||
// either full of trace or has none.
|
||||
let tracing = iris::diagnostics::trace_enabled();
|
||||
let trace_rect = rect(if tracing {
|
||||
Srgba8::rgb(90, 70, 30)
|
||||
} else {
|
||||
Srgba8::rgb(50, 50, 60)
|
||||
})
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||
ctx.state.toggle_trace(rsc);
|
||||
},
|
||||
)
|
||||
.label("Trace input and frames");
|
||||
let trace = (
|
||||
trace_rect,
|
||||
wtext(if tracing { "Trace on" } else { "Trace off" })
|
||||
.size(HEADER_TEXT)
|
||||
.text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
|
||||
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
|
||||
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
|
||||
|
||||
(rect(HEADER_SURFACE), buttons)
|
||||
.stack()
|
||||
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
|
||||
.pad(Padding::top(top_pad))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
impl BenchClient {
|
||||
fn show_message(&mut self, rsc: &mut Rsc, message: &str) {
|
||||
let widget = placeholder(rsc, message);
|
||||
(self.content)(rsc).set(widget);
|
||||
self.screen = None;
|
||||
}
|
||||
|
||||
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
||||
let (screen, tree) = crate::ui::build_tree(rsc, crate::ui::fixture::rows(&self.items));
|
||||
(self.content)(rsc).set(tree);
|
||||
self.screen = Some(screen);
|
||||
}
|
||||
|
||||
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||
let report = self.diagnostics_text(rsc);
|
||||
self.report_display.edit(rsc).set(&report);
|
||||
self.last_report = Some(report);
|
||||
}
|
||||
|
||||
/// Turns the `iris::input`/`iris::frame` trace on or off, redraws the
|
||||
/// switch that says so, and shows the pane that now reports it.
|
||||
fn toggle_trace(&mut self, rsc: &mut Rsc) {
|
||||
let on = !iris::diagnostics::trace_enabled();
|
||||
iris::diagnostics::set_trace(on);
|
||||
log::info!(
|
||||
"iris diagnostics: input/frame trace {}",
|
||||
if on { "on" } else { "off" }
|
||||
);
|
||||
let controls = bench_controls(rsc, self.last_top_pad);
|
||||
(self.top_bar)(rsc).set(controls);
|
||||
self.show_diagnostics(rsc);
|
||||
}
|
||||
|
||||
/// The diagnostics report as text, with no side effect on what is on
|
||||
/// screen -- shared by the `Diagnostics` button (which shows it) and
|
||||
/// the keyboard-open capture (which only logs it), so the two can
|
||||
/// never drift into reporting different things.
|
||||
fn diagnostics_text(&self, rsc: &mut Rsc) -> String {
|
||||
let font = rsc.ui.text.font_diagnostics();
|
||||
let frame_report = match self.android_state().frame_report.report() {
|
||||
Some(stats) => format!("{stats}"),
|
||||
None => "no frames recorded yet".to_string(),
|
||||
};
|
||||
let renderer = match &self.android_state().renderer {
|
||||
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||
};
|
||||
// Insets must be visible without adb so a missing callback can be
|
||||
// distinguished from a callback reporting zero IME height.
|
||||
format!(
|
||||
"{renderer}\n{}\n{}\n{}\n{}",
|
||||
trace_line(
|
||||
iris::diagnostics::trace_enabled(),
|
||||
iris::diagnostics::trace_enabled()
|
||||
),
|
||||
self.android_state().insets_report(),
|
||||
// Which server this build talks to, and what to do when the
|
||||
// answer is "none" -- the bench itself opens a checked-in
|
||||
// fixture and needs no server, so this pane is the only place
|
||||
// an enrolment can be seen to have taken.
|
||||
crate::android::enrollment::status_line(),
|
||||
crate::android::app_log::diagnostics_line()
|
||||
)
|
||||
}
|
||||
|
||||
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||
let report = self.diagnostics_text(rsc);
|
||||
log::info!("iris keyboard diagnostics:\n{report}");
|
||||
}
|
||||
|
||||
fn copy_report(&mut self, rsc: &mut Rsc) {
|
||||
let Some(platform) = &self.platform else {
|
||||
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||
return;
|
||||
};
|
||||
let report = match self.last_report.clone() {
|
||||
Some(report) => report,
|
||||
None => format!(
|
||||
"no benchmark has run yet -- these are the diagnostics instead:\n\n{}",
|
||||
self.diagnostics_text(rsc)
|
||||
),
|
||||
};
|
||||
if platform.copy_to_clipboard("iris bench report", &report) {
|
||||
log::info!("iris bench report: copied to clipboard");
|
||||
} else {
|
||||
log::info!("iris bench report: clipboard copy failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn start_benchmark(&mut self, rsc: &mut Rsc) {
|
||||
if self.running {
|
||||
log::info!("iris bench report: already running");
|
||||
return;
|
||||
}
|
||||
self.running = true;
|
||||
self.android_state_mut().frame_report.reset();
|
||||
self.report_display.edit(rsc).set("Running benchmark...");
|
||||
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
let platform = self.platform.clone();
|
||||
let stream_tail = self.stream_tail.clone();
|
||||
let ime_state = self.ime_state.clone();
|
||||
let platform_hz = platform.as_ref().and_then(|p| p.refresh_rate_hz());
|
||||
let cpu_start = process_cpu_ms();
|
||||
// Read at the start as well as the end, because the switch is on
|
||||
// screen while a run is going: a report that only asked afterwards
|
||||
// would say "on" about a run whose first half has no trace in it
|
||||
// -- the inferred answer presented as the measured one.
|
||||
let trace_at_start = iris::diagnostics::trace_enabled();
|
||||
let run_started_at = Instant::now();
|
||||
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
// The battery sampler runs for the whole run, once a second,
|
||||
// the same cadence `BatterySampler` uses on the Compose side
|
||||
// -- via its own JNI-attached thread, not `ctx.update`, since
|
||||
// a sample needs no widget-tree access.
|
||||
let sampler_done = Arc::new(AtomicBool::new(false));
|
||||
let samples = Arc::new(Mutex::new(Vec::<i32>::new()));
|
||||
let sampler = platform.clone().map(|platform| {
|
||||
let done = sampler_done.clone();
|
||||
let samples = samples.clone();
|
||||
tokio::spawn(async move {
|
||||
while !done.load(Ordering::Relaxed) {
|
||||
if let Some(value) = platform.battery_current_ua() {
|
||||
samples.lock().unwrap().push(value);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let travel = run_fling_phase(&mut ctx, &redraw).await;
|
||||
let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
|
||||
run_type_phase(&mut ctx, &redraw, &platform).await;
|
||||
let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
|
||||
|
||||
sampler_done.store(true, Ordering::Relaxed);
|
||||
if let Some(sampler) = sampler {
|
||||
let _ = sampler.await;
|
||||
}
|
||||
let battery = battery_line(&samples.lock().unwrap());
|
||||
let cpu_line = match (cpu_start, process_cpu_ms()) {
|
||||
(Some(start), Some(end)) => {
|
||||
format!(
|
||||
" process CPU time over this run: {}ms",
|
||||
end.saturating_sub(start)
|
||||
)
|
||||
}
|
||||
_ => " process CPU time over this run: unavailable".to_string(),
|
||||
};
|
||||
let rss_line = match peak_rss_kb() {
|
||||
Some(kb) => format!(" peak RSS: {kb}kB"),
|
||||
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
|
||||
};
|
||||
let total_seconds = run_started_at.elapsed().as_secs_f64();
|
||||
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
state.running = false;
|
||||
let now = Instant::now();
|
||||
let drawn_hz = state.android_state().frame_report.sustained_frame_hz();
|
||||
let refresh_hz = match (drawn_hz, platform_hz) {
|
||||
(Some(d), Some(p)) => d.max(p),
|
||||
(Some(d), None) => d,
|
||||
(None, Some(p)) => p,
|
||||
(None, None) => 60.0,
|
||||
};
|
||||
let hz_line = match (drawn_hz, platform_hz) {
|
||||
(Some(d), Some(p)) if d > p + 5.0 => format!(
|
||||
" (sustained {d:.0}fps, so at least that; the display reported {p:.0}Hz)"
|
||||
),
|
||||
(Some(d), Some(_)) => format!(" (as the display reports it; drew {d:.0}fps)"),
|
||||
(Some(d), None) => {
|
||||
format!(" (sustained {d:.0}fps; the display would not say)")
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
" (as the display reports it; too few frames to measure)".to_string()
|
||||
}
|
||||
(None, None) => " (assumed -- neither measured nor reported)".to_string(),
|
||||
};
|
||||
let phase_lines: String = state
|
||||
.android_state()
|
||||
.frame_report
|
||||
.phase_stats(now, refresh_hz)
|
||||
.iter()
|
||||
.map(|p| format!("{p}\n"))
|
||||
.collect();
|
||||
let per_phase = if phase_lines.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("per phase:\n{phase_lines}\n")
|
||||
};
|
||||
let frames_block = match state.android_state().frame_report.report() {
|
||||
Some(stats) => {
|
||||
let (late, late_pct) =
|
||||
state.android_state().frame_report.late_at_hz(refresh_hz);
|
||||
format!(
|
||||
"frames:\n {} frames over {:.1}s at {:.0}Hz{hz_line} ({:.1}ms \
|
||||
budget)\n \
|
||||
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
|
||||
p99 {:.1}ms\n worst {:.1}ms\n build_p50 {:.1}ms acquire_p50 \
|
||||
{:.1}ms submit_p50 {:.1}ms",
|
||||
stats.total_frames,
|
||||
total_seconds,
|
||||
refresh_hz,
|
||||
1000.0 / refresh_hz as f64,
|
||||
stats.p50.as_secs_f64() * 1000.0,
|
||||
stats.p90.as_secs_f64() * 1000.0,
|
||||
stats.p99.as_secs_f64() * 1000.0,
|
||||
stats.worst.as_secs_f64() * 1000.0,
|
||||
stats.cpu_p50.as_secs_f64() * 1000.0,
|
||||
stats.acquire_p50.as_secs_f64() * 1000.0,
|
||||
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
|
||||
)
|
||||
}
|
||||
None => "frames:\n no frames recorded".to_string(),
|
||||
};
|
||||
let scroll_line = format!(
|
||||
" scroll: {LEGACY_CYCLES} cycles ({} swipes, legacy tween), streamed \
|
||||
{sent}/{total} fixture events",
|
||||
LEGACY_CYCLES * 4
|
||||
);
|
||||
let fling_line = format!(
|
||||
" fling: {FLING_COUNT} flings out + {FLING_COUNT} back at \
|
||||
{FLING_VELOCITY_PX_S}px/s, travel {travel}"
|
||||
);
|
||||
let type_line = format!(
|
||||
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
||||
TYPE_TEXT.chars().count()
|
||||
);
|
||||
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
|
||||
let report = format!(
|
||||
"iris bench report\n{traced}\n{per_phase}{frames_block}\n\nbench:\n\
|
||||
{fling_line}\n{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n\
|
||||
{rss_line}\n{battery}"
|
||||
);
|
||||
log::info!("iris bench report: {report}");
|
||||
state.report_display.edit(rsc).set(&report);
|
||||
state.last_report = Some(report);
|
||||
});
|
||||
redraw.request_redraw();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
|
||||
/// same `ctx.update` every other mutation here goes through) and returns
|
||||
/// its result to the caller's async task -- `ctx.update` alone has no way
|
||||
/// to hand a value back, since the closure only actually runs once the
|
||||
/// next frame callback drains `IrisViewPeer`'s task channel
|
||||
/// (`drain_tasks`). **Must call `redraw.request_redraw()` itself, right
|
||||
/// after enqueueing** -- `ctx.update` only ever pushes onto a channel;
|
||||
/// nothing drains it until something schedules the frame callback that
|
||||
/// calls `drain_tasks`, and a caller relying on some *earlier*,
|
||||
/// already-in-flight `request_redraw()` to cover a *later* `ctx.update`
|
||||
/// deadlocks the moment that earlier callback has already fired and
|
||||
/// drained everything queued before this call existed. Cost a real hang
|
||||
/// in this file's first version of the fling phase: every loop iteration
|
||||
/// after the first sat forever with nothing scheduled to drain it.
|
||||
/// Polls rather than assuming one `POLL_MS` sleep is enough, since a
|
||||
/// slow device's frame callback can lag further than that.
|
||||
async fn read_from_state<T, F>(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
f: F,
|
||||
) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
let _ = tx.send(f(state, rsc));
|
||||
});
|
||||
redraw.request_redraw();
|
||||
loop {
|
||||
if let Ok(value) = rx.try_recv() {
|
||||
return value;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_fling_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) -> String {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("fling");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
|
||||
// `anchor = None` into a real slot before `start` is read.
|
||||
tokio::time::sleep(Duration::from_millis(POLL_MS * 2)).await;
|
||||
let start = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
for _ in 0..FLING_COUNT {
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
||||
animate_scroll(screen.list, rsc);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
wait_for_fling_settle(ctx, redraw).await;
|
||||
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
||||
}
|
||||
let outward = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
for _ in 0..FLING_COUNT {
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
||||
animate_scroll(screen.list, rsc);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
wait_for_fling_settle(ctx, redraw).await;
|
||||
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
||||
}
|
||||
let end = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
format!("start={start} outward={outward} end={end} ticked=frame-loop")
|
||||
}
|
||||
|
||||
async fn read_anchor_position(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) -> String {
|
||||
read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||
Some(screen) => (screen.list)(rsc).anchor_position_display(),
|
||||
None => "idx=none".to_string(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
|
||||
let id = scroll.id();
|
||||
rsc.ui_mut().animate(id);
|
||||
}
|
||||
|
||||
async fn wait_for_fling_settle(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) {
|
||||
let cap = Duration::from_millis(FLING_SETTLE_CAP_MS);
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < cap {
|
||||
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||
Some(screen) => (screen.list)(rsc).is_scrolling(),
|
||||
None => false,
|
||||
})
|
||||
.await;
|
||||
if !still_scrolling {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_stream_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
stream_tail: Vec<SeqEvent>,
|
||||
) -> (usize, usize) {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("stream");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
|
||||
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
|
||||
let mut sent = 0usize;
|
||||
for event in stream_tail.into_iter().take(total) {
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
let old_items = state.items.clone();
|
||||
state.items = fold_event(&state.items, &event);
|
||||
match &state.screen {
|
||||
Some(screen) => screen.apply(rsc, &old_items, &state.items),
|
||||
None => state.rebuild_transcript(rsc),
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
sent += 1;
|
||||
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
(sent, total)
|
||||
}
|
||||
|
||||
async fn run_type_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
platform: &Option<Arc<PlatformHandle>>,
|
||||
) {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("type");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
state.set_focus(Some(screen.composer.field));
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
if let Some(p) = platform {
|
||||
p.show_ime();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
let mut typed = String::new();
|
||||
for ch in TYPE_TEXT.chars() {
|
||||
typed.push(ch);
|
||||
let text = typed.clone();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
screen.composer.field.edit(rsc).set(&text);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
while !typed.is_empty() {
|
||||
typed.pop();
|
||||
let text = typed.clone();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
screen.composer.field.edit(rsc).set(&text);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_keyboard_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
platform: &Option<Arc<PlatformHandle>>,
|
||||
ime_state: &Arc<Mutex<ImeState>>,
|
||||
) -> String {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state
|
||||
.android_state_mut()
|
||||
.frame_report
|
||||
.mark_phase("keyboard");
|
||||
});
|
||||
let mut shown = 0;
|
||||
let mut hidden = 0;
|
||||
for _ in 0..KEYBOARD_CYCLES {
|
||||
let before_shown = ime_state.lock().unwrap().shown_events;
|
||||
if let Some(p) = platform {
|
||||
p.show_ime();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
||||
if ime_state.lock().unwrap().shown_events > before_shown {
|
||||
shown += 1;
|
||||
}
|
||||
|
||||
let before_hidden = ime_state.lock().unwrap().hidden_events;
|
||||
if let Some(p) = platform {
|
||||
p.hide_ime();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
||||
if ime_state.lock().unwrap().hidden_events > before_hidden {
|
||||
hidden += 1;
|
||||
}
|
||||
}
|
||||
if shown == 0 {
|
||||
format!(" keyboard: could not be shown ({KEYBOARD_CYCLES} attempts, 0 confirmed visible)")
|
||||
} else {
|
||||
format!(
|
||||
" keyboard: shown {shown}/{KEYBOARD_CYCLES}, hidden {hidden}/{KEYBOARD_CYCLES} \
|
||||
(confirmed via on_insets_changed)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TYPE_TEXT;
|
||||
|
||||
#[test]
|
||||
fn type_text_is_exactly_600_characters() {
|
||||
assert_eq!(TYPE_TEXT.chars().count(), 600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use android_view::jni::{
|
||||
JNIEnv, JavaVM,
|
||||
objects::{GlobalRef, JObject, JValue},
|
||||
};
|
||||
|
||||
/// `android.os.BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` -- not exposed
|
||||
/// as a constant anywhere reachable without the Android SDK jar, so named
|
||||
/// here with its source rather than left as a bare `2`.
|
||||
const BATTERY_PROPERTY_CURRENT_NOW: i32 = 2;
|
||||
|
||||
pub struct PlatformHandle {
|
||||
vm: JavaVM,
|
||||
view: GlobalRef,
|
||||
}
|
||||
|
||||
impl PlatformHandle {
|
||||
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
|
||||
Self { vm, view }
|
||||
}
|
||||
|
||||
fn context<'e>(&self, env: &mut JNIEnv<'e>) -> Option<JObject<'e>> {
|
||||
env.call_method(
|
||||
self.view.as_obj(),
|
||||
"getContext",
|
||||
"()Landroid/content/Context;",
|
||||
&[],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn system_service<'e>(
|
||||
&self,
|
||||
env: &mut JNIEnv<'e>,
|
||||
context: &JObject<'e>,
|
||||
name: &str,
|
||||
) -> Option<JObject<'e>> {
|
||||
let jname = env.new_string(name).ok()?;
|
||||
env.call_method(
|
||||
context,
|
||||
"getSystemService",
|
||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
&[JValue::Object(jname.as_ref())],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn battery_current_ua(&self) -> Option<i32> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let context = self.context(env)?;
|
||||
let battery_manager = self.system_service(env, &context, "batterymanager")?;
|
||||
let value = env
|
||||
.call_method(
|
||||
&battery_manager,
|
||||
"getIntProperty",
|
||||
"(I)I",
|
||||
&[JValue::Int(BATTERY_PROPERTY_CURRENT_NOW)],
|
||||
)
|
||||
.ok()?
|
||||
.i()
|
||||
.ok()?;
|
||||
if value == 0 || value == i32::MIN {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts `text` on the system clipboard through `ClipboardManager` --
|
||||
/// `true` only if the whole JNI chain (service lookup, `ClipData`,
|
||||
/// `setPrimaryClip`) succeeded.
|
||||
pub fn copy_to_clipboard(&self, label: &str, text: &str) -> bool {
|
||||
self.try_copy_to_clipboard(label, text).is_some()
|
||||
}
|
||||
|
||||
fn try_copy_to_clipboard(&self, label: &str, text: &str) -> Option<()> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let context = self.context(env)?;
|
||||
let clipboard = self.system_service(env, &context, "clipboard")?;
|
||||
let jlabel = env.new_string(label).ok()?;
|
||||
let jtext = env.new_string(text).ok()?;
|
||||
let clip = env
|
||||
.call_static_method(
|
||||
"android/content/ClipData",
|
||||
"newPlainText",
|
||||
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
|
||||
&[
|
||||
JValue::Object(jlabel.as_ref()),
|
||||
JValue::Object(jtext.as_ref()),
|
||||
],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()?;
|
||||
env.call_method(
|
||||
&clipboard,
|
||||
"setPrimaryClip",
|
||||
"(Landroid/content/ClipData;)V",
|
||||
&[JValue::Object(&clip)],
|
||||
)
|
||||
.ok()?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
pub fn refresh_rate_hz(&self) -> Option<f32> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let display = env
|
||||
.call_method(
|
||||
self.view.as_obj(),
|
||||
"getDisplay",
|
||||
"()Landroid/view/Display;",
|
||||
&[],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()?;
|
||||
if display.is_null() {
|
||||
return None;
|
||||
}
|
||||
let rate = env
|
||||
.call_method(&display, "getRefreshRate", "()F", &[])
|
||||
.ok()?
|
||||
.f()
|
||||
.ok()?;
|
||||
if rate > 0.0 { Some(rate) } else { None }
|
||||
}
|
||||
|
||||
pub fn show_ime(&self) -> bool {
|
||||
self.try_toggle_ime(true).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn hide_ime(&self) -> bool {
|
||||
self.try_toggle_ime(false).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn try_toggle_ime(&self, show: bool) -> Option<bool> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let context = self.context(env)?;
|
||||
let imm = self.system_service(env, &context, "input_method")?;
|
||||
if show {
|
||||
env.call_method(
|
||||
&imm,
|
||||
"showSoftInput",
|
||||
"(Landroid/view/View;I)Z",
|
||||
&[JValue::Object(self.view.as_obj()), JValue::Int(0)],
|
||||
)
|
||||
.ok()?
|
||||
.z()
|
||||
.ok()
|
||||
} else {
|
||||
let token = env
|
||||
.call_method(
|
||||
self.view.as_obj(),
|
||||
"getWindowToken",
|
||||
"()Landroid/os/IBinder;",
|
||||
&[],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()?;
|
||||
env.call_method(
|
||||
&imm,
|
||||
"hideSoftInputFromWindow",
|
||||
"(Landroid/os/IBinder;I)Z",
|
||||
&[JValue::Object(&token), JValue::Int(0)],
|
||||
)
|
||||
.ok()?
|
||||
.z()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! The JNI half of `DevLogProvider`: reading this process's own log ring
|
||||
//! for a `ContentProvider` that Dev Updater queries.
|
||||
|
||||
use android_view::jni::JNIEnv;
|
||||
use android_view::jni::objects::{JClass, JObject, JString};
|
||||
use android_view::jni::sys::{jlong, jobjectArray};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Gated with its one reader: the tabs demo links no `client-core` and so
|
||||
/// has no ring to lay out, and an ungated constant is a warning in that
|
||||
/// build (`iris-android-app` without `transcript-screen`).
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
const FIELDS_PER_LINE: usize = 5;
|
||||
|
||||
/// The authority the provider registered itself under, once it has been
|
||||
/// created. `None` until then, which is a state worth being able to say:
|
||||
/// a provider Android never instantiated and one that is answering look
|
||||
/// the same from inside this process otherwise.
|
||||
static AUTHORITY: OnceLock<String> = OnceLock::new();
|
||||
|
||||
#[cfg(feature = "bench")]
|
||||
pub fn authority() -> Option<&'static str> {
|
||||
AUTHORITY.get().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The directory is taken here as well as in
|
||||
/// `MainActivity.nativeSetFilesDir` because **the provider is often the
|
||||
/// only thing running**: once the app has died, Dev Updater's query
|
||||
/// starts the process for the provider alone, so no activity ever runs
|
||||
/// and the panic hook's file would never be replayed into the ring. That
|
||||
/// is precisely the run whose log is being asked for. Whichever of the
|
||||
/// two arrives first does the replay; `set_crash_dir` deletes the file,
|
||||
/// so the second finds nothing and says nothing.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
authority: JString,
|
||||
files_dir: JString,
|
||||
) {
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
if let Some(dir) = string_arg(&mut env, &files_dir) {
|
||||
crate::android::app_log::set_crash_dir(std::path::Path::new(&dir));
|
||||
}
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
let _ = &files_dir;
|
||||
let Some(authority) = string_arg(&mut env, &authority) else {
|
||||
return;
|
||||
};
|
||||
log::info!("iris devlog: serving this app's log at content://{authority}");
|
||||
let _ = AUTHORITY.set(authority);
|
||||
}
|
||||
|
||||
fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
|
||||
if value.is_null() {
|
||||
return None;
|
||||
}
|
||||
env.get_string(value).ok().map(Into::into)
|
||||
}
|
||||
|
||||
/// `newest_seq` is `-1` for a ring nothing has been written to, which is
|
||||
/// what tells a reader holding a cursor that this process **restarted**:
|
||||
/// the ring is in memory, so a new process starts again at zero and a
|
||||
/// stale cursor would otherwise skip everything silently.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeStatus(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jobjectArray {
|
||||
string_array(&mut env, &status_fields())
|
||||
}
|
||||
|
||||
/// Inclusive of `since` because [`crate::client::log_ring::LogRing::since`]
|
||||
/// is, and one definition of the cursor is what keeps the app's own
|
||||
/// uploaded report and this provider describing the same lines.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeLinesSince(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
since: jlong,
|
||||
) -> jobjectArray {
|
||||
// A negative cursor is a caller asking for everything, not an error to
|
||||
// take the app down over: the provider is a diagnostic.
|
||||
string_array(&mut env, &line_fields(since.max(0) as u64))
|
||||
}
|
||||
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
fn status_fields() -> Vec<String> {
|
||||
let ring = crate::client::log_ring::process_ring();
|
||||
vec![
|
||||
ring.len().to_string(),
|
||||
ring.dropped().to_string(),
|
||||
ring.newest_seq().map_or(-1, |seq| seq as i64).to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The tabs demo links no `client-core` and keeps no ring, so it holds
|
||||
/// nothing and has never dropped anything -- which is the truth, not a
|
||||
/// stand-in. The natives are still exported there, because a `native`
|
||||
/// method Java declares and the library does not is an
|
||||
/// `UnsatisfiedLinkError` the moment the class loads.
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
fn status_fields() -> Vec<String> {
|
||||
vec!["0".to_string(), "0".to_string(), "-1".to_string()]
|
||||
}
|
||||
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
fn line_fields(since: u64) -> Vec<String> {
|
||||
let (lines, _next) = crate::client::log_ring::process_ring().since(since);
|
||||
let mut fields = Vec::with_capacity(lines.len() * FIELDS_PER_LINE);
|
||||
for line in lines {
|
||||
fields.push(line.seq.to_string());
|
||||
fields.push(line.at_ms.to_string());
|
||||
fields.push(line.level.to_string());
|
||||
fields.push(line.target);
|
||||
fields.push(line.message);
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
fn line_fields(_since: u64) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Null rather than a panic across the JNI boundary: `DevLogProvider`
|
||||
/// reads it as "the provider could not answer" and returns no cursor,
|
||||
/// which Dev Updater already draws as a distinct state. Taking the app
|
||||
/// down to report that its diagnostic is unavailable would be worse than
|
||||
/// the diagnostic being unavailable.
|
||||
fn string_array(env: &mut JNIEnv, fields: &[String]) -> jobjectArray {
|
||||
let null = std::ptr::null_mut();
|
||||
let Ok(class) = env.find_class("java/lang/String") else {
|
||||
return null;
|
||||
};
|
||||
let Ok(array) = env.new_object_array(fields.len() as i32, class, JObject::null()) else {
|
||||
return null;
|
||||
};
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
let Ok(value) = env.new_string(field) else {
|
||||
return null;
|
||||
};
|
||||
if env
|
||||
.set_object_array_element(&array, index as i32, value)
|
||||
.is_err()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
array.into_raw()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#[cfg(not(feature = "bench"))]
|
||||
use crate::client::api::UreqTransport;
|
||||
use crate::client::config::{EnrolledServer, EnrollmentStore};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static FILES_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
pub fn set_files_dir(dir: PathBuf) {
|
||||
if let Err(existing) = FILES_DIR.set(dir.clone()) {
|
||||
assert_eq!(
|
||||
existing, dir,
|
||||
"the app's files directory was set twice with different paths"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn store() -> Option<EnrollmentStore> {
|
||||
FILES_DIR.get().map(EnrollmentStore::new)
|
||||
}
|
||||
|
||||
pub enum Status {
|
||||
Enrolled(EnrolledServer),
|
||||
NotEnrolled,
|
||||
/// The question could not be answered -- the activity never handed a
|
||||
/// files directory over, or the file is there and unreadable. Kept
|
||||
/// apart from `NotEnrolled` because the two want different actions
|
||||
/// from whoever is looking.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
pub fn status() -> Status {
|
||||
let Some(store) = store() else {
|
||||
return Status::Unknown("the activity never handed over a files directory".to_string());
|
||||
};
|
||||
match store.load() {
|
||||
Ok(Some(server)) => Status::Enrolled(server),
|
||||
Ok(None) => Status::NotEnrolled,
|
||||
Err(error) => Status::Unknown(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One line for the diagnostics pane. The three states read differently on
|
||||
/// purpose: "not enrolled" says what to do about it, and "couldn't tell"
|
||||
/// must not be mistaken for it.
|
||||
#[cfg(feature = "bench")]
|
||||
pub fn status_line() -> String {
|
||||
match status() {
|
||||
Status::Enrolled(server) => format!("enrolled: {}:{}", server.host, server.port),
|
||||
Status::NotEnrolled => "not enrolled -- open the enrol link from Dev Updater".to_string(),
|
||||
Status::Unknown(why) => format!("enrolment unreadable: {why}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
|
||||
let server = EnrolledServer::parse_link(uri)?;
|
||||
let store = store().ok_or("the app has no files directory to save an enrollment in")?;
|
||||
store
|
||||
.save(&server)
|
||||
.map_err(|error| format!("couldn't save the enrollment: {error}"))?;
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// Gated to the same builds as `transcript_client`, its only caller: the
|
||||
/// bench build opens a checked-in fixture and reaches no server, so
|
||||
/// compiling this into it would be a warning about dead code that is
|
||||
/// dead on purpose.
|
||||
///
|
||||
/// Every failure here is a sentence a screen can show, because there is
|
||||
/// nowhere else for it to go: this app has no `logcat` on the phone it is
|
||||
/// built for.
|
||||
#[cfg(not(feature = "bench"))]
|
||||
pub fn transport() -> Result<UreqTransport, String> {
|
||||
let server = match status() {
|
||||
Status::Enrolled(server) => server,
|
||||
Status::NotEnrolled => {
|
||||
return Err("Not enrolled yet -- open the enrol link from Dev Updater.".to_string());
|
||||
}
|
||||
Status::Unknown(why) => return Err(format!("Couldn't read the enrollment: {why}")),
|
||||
};
|
||||
let ca_pem = server.ca_pem.as_ref().ok_or(
|
||||
"The enrollment link carried no CA, so there is nothing to pin. \
|
||||
Enrol again with a link minted by this server.",
|
||||
)?;
|
||||
UreqTransport::new(server.base_url(), &server.token, ca_pem.as_bytes())
|
||||
.map_err(|error| error.message)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use android_view::{
|
||||
Context, View,
|
||||
jni::{
|
||||
JNIEnv, JavaVM,
|
||||
objects::{JClass, JString},
|
||||
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
|
||||
},
|
||||
register_view_class,
|
||||
};
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
use iris::prelude::*;
|
||||
use log::LevelFilter;
|
||||
use std::ffi::c_void;
|
||||
|
||||
/// The app's own log ring and its upload -- only where `client-core` is
|
||||
/// linked, which is every build that has a server to send to. The plain
|
||||
/// tabs demo keeps `android_logger` alone, as it always had.
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
mod app_log;
|
||||
#[cfg(feature = "bench")]
|
||||
mod bench_client;
|
||||
#[cfg(feature = "bench")]
|
||||
mod bench_jni;
|
||||
mod devlog;
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
mod enrollment;
|
||||
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||
mod transcript_client;
|
||||
|
||||
/// The app's `View` subclass, matching the Java side's package --
|
||||
/// `app/src/main/java/dev/iris/android/demo/IrisView.java`.
|
||||
const VIEW_CLASS: &str = "dev/iris/android/demo/IrisView";
|
||||
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
pub struct Client {
|
||||
ui_state: AndroidUiState,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
impl HasAndroidUiState for Client {
|
||||
fn android_state(&self) -> &AndroidUiState {
|
||||
&self.ui_state
|
||||
}
|
||||
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||
&mut self.ui_state
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
impl AndroidAppState for Client {
|
||||
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||
// `widgets.info` is the winit example's frame-debug readout, kept
|
||||
// current from `DefaultAppState::window_event` -- android-view has
|
||||
// no per-frame hook to drive the equivalent from here yet, so it
|
||||
// is left at its built "" text rather than wired to nothing.
|
||||
let _ = tabs_ui::build(rsc, &mut ui_state);
|
||||
Self { ui_state }
|
||||
}
|
||||
|
||||
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
type ActiveClient = Client;
|
||||
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||
type ActiveClient = transcript_client::TranscriptClient;
|
||||
#[cfg(feature = "bench")]
|
||||
type ActiveClient = bench_client::BenchClient;
|
||||
|
||||
extern "system" fn new_view_peer<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
view: View<'local>,
|
||||
context: Context<'local>,
|
||||
) -> jlong {
|
||||
iris::android::new_peer::<ActiveClient>(env, view, context)
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Interacting with JNI at load time is always unsafe at some level --
|
||||
/// mirrors android-view's own demo, which carries the same comment.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
|
||||
// The ring in front of `android_logger` where there is one (see
|
||||
// `app_log`), and `android_logger` alone otherwise. Both install the
|
||||
// same tag and level, so `logcat` cannot tell the two builds apart --
|
||||
// the ring only adds a second reader.
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
app_log::install(LevelFilter::Debug);
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(LevelFilter::Debug)
|
||||
.with_tag("iris-android-app"),
|
||||
);
|
||||
let vm = unsafe { JavaVM::from_raw(vm) }.unwrap();
|
||||
let mut env = vm.get_env().unwrap();
|
||||
register_view_class(&mut env, VIEW_CLASS, new_view_peer);
|
||||
iris::android::register_native_methods(&mut env, VIEW_CLASS);
|
||||
JNI_VERSION_1_6
|
||||
}
|
||||
|
||||
/// `MainActivity.nativeSetFilesDir` -- the app's private directory, handed
|
||||
/// over before the view exists because that is where the enrollment is
|
||||
/// read from and written to (`enrollment`'s module doc).
|
||||
///
|
||||
/// Exported by name rather than registered through `RegisterNatives`: the
|
||||
/// view's methods are registered because `android-view` owns that class
|
||||
/// and hands out one function pointer, whereas these two are this app's
|
||||
/// own activity and the mangled name is the whole of what is needed.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
dir: JString,
|
||||
) {
|
||||
let Some(dir) = jstring(&mut env, dir) else {
|
||||
return;
|
||||
};
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
{
|
||||
app_log::set_crash_dir(std::path::Path::new(&dir));
|
||||
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
|
||||
}
|
||||
log::debug!("iris app: files directory is {dir}");
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
uri: JString,
|
||||
) {
|
||||
let Some(uri) = jstring(&mut env, uri) else {
|
||||
return;
|
||||
};
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
match enrollment::apply_link(&uri) {
|
||||
// Never the token: `wg-app-link`'s enroll module forbids logging
|
||||
// it, and this line would otherwise be the one place it leaked.
|
||||
Ok(server) => log::info!("iris app: enrolled with {}:{}", server.host, server.port),
|
||||
Err(error) => log::warn!("iris app: that enrolment link was refused -- {error}"),
|
||||
}
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
log::warn!("iris app: {uri} arrived, but this build has no server to enrol with");
|
||||
}
|
||||
|
||||
fn jstring(env: &mut JNIEnv, value: JString) -> Option<String> {
|
||||
if value.is_null() {
|
||||
log::warn!("iris app: the activity passed a null string across JNI");
|
||||
return None;
|
||||
}
|
||||
match env.get_string(&value) {
|
||||
Ok(value) => Some(value.into()),
|
||||
Err(error) => {
|
||||
log::warn!("iris app: couldn't read a string from the activity -- {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
use crate::client::api::{ApiClient, UreqTransport};
|
||||
use crate::client::event_stream::{StreamItem, follow_session_events};
|
||||
use crate::client::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
||||
use event_model::SeqEvent;
|
||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||
use iris::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub struct TranscriptClient {
|
||||
ui_state: AndroidUiState,
|
||||
/// The screen's own content -- everything under the fixed
|
||||
/// [`frame_report_controls`] bar, which is built once (`new`, below)
|
||||
/// and never touched by `show_message`/`rebuild_transcript`'s own
|
||||
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
|
||||
/// by rebuilding the session list beside it.
|
||||
content: WeakWidget<WidgetPtr>,
|
||||
screen: Option<crate::ui::TranscriptScreen>,
|
||||
items: Vec<TranscriptItem>,
|
||||
session_id: Option<String>,
|
||||
generation: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl HasAndroidUiState for TranscriptClient {
|
||||
fn android_state(&self) -> &AndroidUiState {
|
||||
&self.ui_state
|
||||
}
|
||||
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||
&mut self.ui_state
|
||||
}
|
||||
}
|
||||
|
||||
fn build_transport() -> Result<UreqTransport, String> {
|
||||
crate::android::enrollment::transport()
|
||||
}
|
||||
|
||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||
wtext(message.to_string())
|
||||
.color(PaintId::WHITE)
|
||||
.wrap(true)
|
||||
.pad(16)
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
|
||||
type Rsc = AndroidRsc<TranscriptClient>;
|
||||
let report_rect = rect(Srgba8::rgb(50, 50, 60))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
|
||||
.state
|
||||
.android_state()
|
||||
.frame_report
|
||||
.report()
|
||||
{
|
||||
Some(stats) => log::info!("iris frame report: {stats}"),
|
||||
None => log::info!(
|
||||
"iris frame report: no frames recorded -- scroll first, then press this"
|
||||
),
|
||||
},
|
||||
)
|
||||
.label("Frame report");
|
||||
let report = (
|
||||
report_rect,
|
||||
wtext("Frame report").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(8)
|
||||
.add(rsc);
|
||||
|
||||
let reset_rect = rect(Srgba8::rgb(70, 40, 40))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
||||
ctx.state.android_state_mut().frame_report.reset();
|
||||
log::info!("iris frame report: reset");
|
||||
},
|
||||
)
|
||||
.label("Reset frame report");
|
||||
let reset = (
|
||||
reset_rect,
|
||||
wtext("Reset").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(8)
|
||||
.add(rsc);
|
||||
|
||||
(report, reset).span(Dir::RIGHT).height(56).add(rsc)
|
||||
}
|
||||
|
||||
impl AndroidAppState for TranscriptClient {
|
||||
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||
let content = WidgetPtr::new().add(rsc);
|
||||
let loading = placeholder(rsc, "Loading sessions...");
|
||||
content(rsc).set(loading);
|
||||
|
||||
let tree = (frame_report_controls(rsc), content.height(rest(1)))
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
ui_state.set_root(rsc, tree);
|
||||
|
||||
let mut client = Self {
|
||||
ui_state,
|
||||
content,
|
||||
screen: None,
|
||||
items: Vec::new(),
|
||||
session_id: None,
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
};
|
||||
client.spawn_fetch_sessions(rsc);
|
||||
client
|
||||
}
|
||||
|
||||
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||
// No screen stack of its own -- same "let the activity finish"
|
||||
// answer `iris-android-app`'s tabs `Client` already gives.
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptClient {
|
||||
fn show_message(&mut self, rsc: &mut AndroidRsc<Self>, message: &str) {
|
||||
let widget = placeholder(rsc, message);
|
||||
(self.content)(rsc).set(widget);
|
||||
self.screen = None;
|
||||
}
|
||||
|
||||
fn spawn_fetch_sessions(&mut self, rsc: &mut AndroidRsc<Self>) {
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
let my_generation = self.generation.load(Ordering::SeqCst);
|
||||
let generation = self.generation.clone();
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
let outcome = match build_transport() {
|
||||
Ok(transport) => ApiClient::new(transport)
|
||||
.fetch_sessions()
|
||||
.map_err(|e| e.to_string()),
|
||||
Err(e) => Err(format!("couldn't set up TLS: {e}")),
|
||||
};
|
||||
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||
if generation.load(Ordering::SeqCst) != my_generation {
|
||||
return;
|
||||
}
|
||||
match outcome {
|
||||
Ok(sessions) => match sessions.into_iter().next() {
|
||||
Some(session) => state.select_session(rsc, session.id),
|
||||
None => state.show_message(rsc, "No sessions on the sandbox server."),
|
||||
},
|
||||
Err(message) => {
|
||||
state.show_message(rsc, &format!("Couldn't list sessions: {message}"))
|
||||
}
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
});
|
||||
}
|
||||
|
||||
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
|
||||
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.items.clear();
|
||||
self.session_id = Some(session_id.clone());
|
||||
self.show_message(rsc, "Loading transcript...");
|
||||
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
let live_generation = self.generation.clone();
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
let transports =
|
||||
build_transport().and_then(|rest| build_transport().map(|stream| (rest, stream)));
|
||||
let (rest, stream_transport) = match transports {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
let message = format!("couldn't set up TLS: {e}");
|
||||
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||
if live_generation.load(Ordering::SeqCst) == my_generation {
|
||||
state.show_message(rsc, &message);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let api = ApiClient::new(rest);
|
||||
|
||||
let page: Result<Vec<serde_json::Value>, String> = api
|
||||
.fetch_transcript_page(&session_id, None, 200, true)
|
||||
.map_err(|e| e.to_string());
|
||||
let after = page
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|values| values.last())
|
||||
.and_then(crate::client::transcript_fold::raw_seq)
|
||||
.unwrap_or(0);
|
||||
let result = page.and_then(|values| fold_page(&values));
|
||||
|
||||
{
|
||||
let live_generation = live_generation.clone();
|
||||
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(items) => {
|
||||
state.items = items;
|
||||
state.rebuild_transcript(rsc);
|
||||
}
|
||||
Err(message) => {
|
||||
state.show_message(rsc, &format!("Couldn't load transcript: {message}"))
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
redraw.request_redraw();
|
||||
|
||||
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||
return;
|
||||
}
|
||||
let _ =
|
||||
follow_session_events(
|
||||
&stream_transport,
|
||||
&session_id,
|
||||
after,
|
||||
move |item| match item {
|
||||
StreamItem::Open | StreamItem::Reset => {
|
||||
live_generation.load(Ordering::SeqCst) == my_generation
|
||||
}
|
||||
StreamItem::Event { event, .. } => {
|
||||
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||
return false;
|
||||
}
|
||||
let live_generation = live_generation.clone();
|
||||
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||
return;
|
||||
}
|
||||
state.apply_event(rsc, &event);
|
||||
});
|
||||
redraw.request_redraw();
|
||||
true
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
|
||||
let in_progress = self
|
||||
.screen
|
||||
.as_ref()
|
||||
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
|
||||
.filter(|t| !t.is_empty());
|
||||
|
||||
let rows = group_tool_runs(&self.items);
|
||||
let (screen, tree) = crate::ui::build_tree(rsc, rows);
|
||||
|
||||
if let Some(text) = in_progress {
|
||||
screen.composer.field.edit(rsc).set(&text);
|
||||
}
|
||||
if let Some(session_id) = self.session_id.clone() {
|
||||
let field = screen.composer.field;
|
||||
rsc.register_event(field, Submit, move |ctx, rsc| {
|
||||
let text = field.edit(rsc).take();
|
||||
let text = text.trim().to_string();
|
||||
if !text.is_empty() {
|
||||
ctx.state.send_message(session_id.clone(), text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(self.content)(rsc).set(tree);
|
||||
self.screen = Some(screen);
|
||||
}
|
||||
|
||||
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
|
||||
let old_items = self.items.clone();
|
||||
self.items = fold_event(&self.items, event);
|
||||
match &self.screen {
|
||||
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||
None => self.rebuild_transcript(rsc),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(&mut self, session_id: String, text: String) {
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(transport) = build_transport() {
|
||||
let api = ApiClient::new(transport);
|
||||
let _ = api.send_message(&session_id, &text, &[]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use ai_app::desktop::{app, startup};
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = startup::load_startup_config() {
|
||||
eprintln!("desktop-app: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
app::run();
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnsiPalette {
|
||||
pub colours: [Rgb; 16],
|
||||
pub foreground: Rgb,
|
||||
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,
|
||||
}
|
||||
|
||||
#[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}';
|
||||
|
||||
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||
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') {
|
||||
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' {
|
||||
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 }
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
chars.len()
|
||||
} else {
|
||||
let params: String = chars[at + 2..end].iter().collect();
|
||||
on_csi(¶ms, chars[end]);
|
||||
end + 1
|
||||
}
|
||||
}
|
||||
']' | 'P' | 'X' | '^' | '_' => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||
|
||||
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::*;
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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() {
|
||||
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,546 @@
|
||||
use std::io::Read;
|
||||
|
||||
use event_model::SeqEvent;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// `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 {}
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
pub trait Transport: Send + Sync {
|
||||
fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<Body>,
|
||||
) -> Result<RawResponse, ApiError>;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub struct ApiClient<T: Transport> {
|
||||
transport: T,
|
||||
}
|
||||
|
||||
impl<T: Transport> ApiClient<T> {
|
||||
pub fn new(transport: T) -> Self {
|
||||
Self { transport }
|
||||
}
|
||||
|
||||
/// The transport underneath, for a caller that needs the raw SSE
|
||||
/// stream (`event_stream::follow_session_events`) rather than one of
|
||||
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
|
||||
/// is the one that does.
|
||||
pub fn transport(&self) -> &T {
|
||||
&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::client::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> {
|
||||
self.json_request(
|
||||
"GET",
|
||||
&transcript_path(session_id, before, limit, coalesce, None),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fetch_transcript_lines(
|
||||
&self,
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
after: Option<u64>,
|
||||
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
|
||||
let path = transcript_path(session_id, before, limit, coalesce, after);
|
||||
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
|
||||
raw.into_iter()
|
||||
.map(|value| {
|
||||
let line = value.get().to_string();
|
||||
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
|
||||
message: format!(
|
||||
"the server sent a transcript line this build couldn't parse: {e}"
|
||||
),
|
||||
status: None,
|
||||
})?;
|
||||
Ok((line, event))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn transcript_path(
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
after: Option<u64>,
|
||||
) -> String {
|
||||
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");
|
||||
}
|
||||
if let Some(after) = after {
|
||||
path.push_str(&format!("&after={after}"));
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
pub struct UreqTransport {
|
||||
agent: ureq::Agent,
|
||||
base_url: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl UreqTransport {
|
||||
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)
|
||||
.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")
|
||||
.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,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
#[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");
|
||||
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,319 @@
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
|
||||
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
|
||||
/// because an app built on the machine its server runs on pins the CA at
|
||||
/// build time and needs nothing from the link; one built elsewhere -- the
|
||||
/// iris Android client is cross-compiled in a VM and run against the
|
||||
/// host's server -- has no other way to get it. A public certificate
|
||||
/// rather than a secret, so it costs the link nothing but length.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EnrolledServer {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
pub ca_pem: Option<String>,
|
||||
}
|
||||
|
||||
impl EnrolledServer {
|
||||
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
|
||||
/// here, because that is what every consumer of it wants
|
||||
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
|
||||
/// at). A `ca` that does not decode fails the whole link rather than
|
||||
/// enrolling a server with no trust anchor: the link said which
|
||||
/// certificate to pin, and quietly not pinning it is the one outcome
|
||||
/// nothing downstream could notice.
|
||||
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;
|
||||
let mut ca = 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),
|
||||
"ca" => ca = 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'"))?;
|
||||
let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?;
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
token,
|
||||
ca_pem,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("https://{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
fn pem_from_link_param(ca: &str) -> Result<String, String> {
|
||||
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(ca.as_bytes())
|
||||
.map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?;
|
||||
let body = base64::engine::general_purpose::STANDARD.encode(&der);
|
||||
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
|
||||
for line in body.as_bytes().chunks(64) {
|
||||
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
|
||||
pem.push('\n');
|
||||
}
|
||||
pem.push_str("-----END CERTIFICATE-----\n");
|
||||
Ok(pem)
|
||||
}
|
||||
|
||||
/// Where one client keeps the enrollment it should not have to be told
|
||||
/// about a second time. `dir` is the caller's, because that is the only
|
||||
/// part that differs by platform -- see this module's doc.
|
||||
pub struct EnrollmentStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl EnrollmentStore {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
Self { dir: dir.into() }
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
fn file(&self) -> PathBuf {
|
||||
self.dir.join("enrollment.json")
|
||||
}
|
||||
|
||||
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let path = self.file();
|
||||
let json = serde_json::to_vec_pretty(server)
|
||||
.expect("EnrolledServer holds nothing that fails to serialise");
|
||||
std::fs::write(&path, json)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
|
||||
let path = self.file();
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let server = serde_json::from_slice(&bytes).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("{} is not a valid enrollment ({e})", path.display()),
|
||||
)
|
||||
})?;
|
||||
Ok(Some(server))
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
ca_pem: None,
|
||||
}
|
||||
);
|
||||
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() {
|
||||
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_ca_in_the_link_comes_back_as_pem() {
|
||||
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
|
||||
let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der);
|
||||
let server =
|
||||
EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}"))
|
||||
.unwrap();
|
||||
let pem = server.ca_pem.expect("the link carried a CA");
|
||||
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}");
|
||||
assert!(
|
||||
pem.trim_end().ends_with("-----END CERTIFICATE-----"),
|
||||
"{pem}"
|
||||
);
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(
|
||||
pem.lines()
|
||||
.filter(|l| !l.starts_with("-----"))
|
||||
.collect::<String>()
|
||||
)
|
||||
.unwrap(),
|
||||
der
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ca_parameter_is_none_not_an_error() {
|
||||
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
|
||||
assert_eq!(server.ca_pem, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ca_that_does_not_decode_fails_the_link() {
|
||||
let err =
|
||||
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url")
|
||||
.unwrap_err();
|
||||
assert!(err.contains("ca"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_enrollment_reads_back_the_same() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = EnrollmentStore::new(dir.path());
|
||||
let server = EnrolledServer {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8547,
|
||||
token: "tok".to_string(),
|
||||
ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()),
|
||||
};
|
||||
store.save(&server).unwrap();
|
||||
assert_eq!(store.load().unwrap(), Some(server));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_saved_yet_is_none_not_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_enrollment_without_a_ca_still_loads() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = EnrollmentStore::new(dir.path());
|
||||
std::fs::create_dir_all(dir.path()).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("enrollment.json"),
|
||||
br#"{"host":"h","port":1,"token":"t"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(store.load().unwrap().unwrap().ca_pem, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn the_saved_file_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = EnrollmentStore::new(dir.path());
|
||||
store
|
||||
.save(&EnrolledServer {
|
||||
host: "h".to_string(),
|
||||
port: 1,
|
||||
token: "t".to_string(),
|
||||
ca_pem: None,
|
||||
})
|
||||
.unwrap();
|
||||
let mode = std::fs::metadata(dir.path().join("enrollment.json"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_file_is_named_in_the_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap();
|
||||
let err = EnrollmentStore::new(dir.path()).load().unwrap_err();
|
||||
assert!(err.to_string().contains("enrollment.json"));
|
||||
}
|
||||
|
||||
#[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,74 @@
|
||||
/// A tool's timeout arrives as `480000`, which nobody reads as eight
|
||||
/// minutes. The rule has two halves, because a short span and a long one
|
||||
/// are read for different things. Under a minute the question is "roughly
|
||||
/// how long", so only the largest unit is shown and a fraction carries the
|
||||
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
|
||||
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
|
||||
/// units are left out rather than written as zero.
|
||||
pub fn format_millis(ms: i64) -> String {
|
||||
if ms < 0 {
|
||||
return format!("-{}", format_millis(-ms));
|
||||
}
|
||||
if ms < 1000 {
|
||||
return format!("{ms}ms");
|
||||
}
|
||||
if ms < 60_000 {
|
||||
let tenths = (ms + 50) / 100;
|
||||
let (whole, rest) = (tenths / 10, tenths % 10);
|
||||
return if rest == 0 {
|
||||
format!("{whole}s")
|
||||
} else {
|
||||
format!("{whole}.{rest}s")
|
||||
};
|
||||
}
|
||||
let seconds = ms / 1000;
|
||||
[
|
||||
("d", seconds / 86_400),
|
||||
("h", seconds / 3600 % 24),
|
||||
("m", seconds / 60 % 60),
|
||||
("s", seconds % 60),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(_, n)| *n > 0)
|
||||
.map(|(unit, n)| format!("{n}{unit}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub fn format_millis_text(text: &str) -> String {
|
||||
match text.trim().parse::<i64>() {
|
||||
Ok(ms) => format_millis(ms),
|
||||
Err(_) => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn under_a_minute_is_the_largest_unit_alone() {
|
||||
assert_eq!(format_millis(30), "30ms");
|
||||
assert_eq!(format_millis(999), "999ms");
|
||||
assert_eq!(format_millis(1000), "1s");
|
||||
assert_eq!(format_millis(2500), "2.5s");
|
||||
assert_eq!(format_millis(2460), "2.5s");
|
||||
assert_eq!(format_millis(59_900), "59.9s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
|
||||
assert_eq!(format_millis(480_000), "8m");
|
||||
assert_eq!(format_millis(60_000), "1m");
|
||||
assert_eq!(format_millis(90_000), "1m 30s");
|
||||
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
|
||||
assert_eq!(format_millis(432_240_000), "5d 4m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_whole_number_of_milliseconds_is_rewritten() {
|
||||
assert_eq!(format_millis_text(" 480000 "), "8m");
|
||||
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
|
||||
assert_eq!(format_millis_text(""), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
use event_model::SeqEvent;
|
||||
|
||||
use crate::client::api::{ApiError, Transport};
|
||||
use crate::client::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 {
|
||||
Open,
|
||||
Reset,
|
||||
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;
|
||||
};
|
||||
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::client::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,536 @@
|
||||
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 {
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Rules {
|
||||
pub keywords: HashSet<&'static str>,
|
||||
pub line_comments: Vec<&'static str>,
|
||||
pub line_comments_at_word_start: bool,
|
||||
pub block_comment: Option<BlockComment>,
|
||||
pub quotes: Vec<Quote>,
|
||||
pub attributes: Attributes,
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Quote {
|
||||
pub open: &'static str,
|
||||
pub close: &'static str,
|
||||
pub escapes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum Attributes {
|
||||
#[default]
|
||||
None,
|
||||
AtWord,
|
||||
HashBracket,
|
||||
HashLine,
|
||||
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()
|
||||
}
|
||||
|
||||
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()
|
||||
},
|
||||
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,
|
||||
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()
|
||||
},
|
||||
Language::Markdown => Rules::default(),
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
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";
|
||||
|
||||
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";
|
||||
|
||||
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 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,618 @@
|
||||
use super::{Kind, Span};
|
||||
|
||||
const BULLETS: &str = "-*+";
|
||||
const RULE_MARKERS: &str = "-*_=";
|
||||
const EMPHASIS: &str = "*_~";
|
||||
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;
|
||||
let mut table = false;
|
||||
loop {
|
||||
let end = self.line_end(at);
|
||||
if let Some(open) = fence.clone() {
|
||||
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
|
||||
}
|
||||
|
||||
fn line_end(&self, at: usize) -> usize {
|
||||
self.code[at..]
|
||||
.iter()
|
||||
.position(|&c| c == '\n')
|
||||
.map(|p| at + p)
|
||||
.unwrap_or(self.code.len())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn table_delimiter(&self, start: usize, end: usize) -> bool {
|
||||
let mut dashes = false;
|
||||
let mut pipes = false;
|
||||
for c in &self.code[self.indented(start, end)..end] {
|
||||
match c {
|
||||
'-' => 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
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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);
|
||||
let indented = self.indented(run_end, end);
|
||||
self.emit(indented, end, Kind::Metadata);
|
||||
Some(self.code[run_start..run_end].to_vec())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn structure(&mut self, start: usize, end: usize) {
|
||||
let mut at = start;
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 &c in &self.code[start..end] {
|
||||
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
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
fn inline(&mut self, start: usize, end: usize) {
|
||||
let mut at = start;
|
||||
while at < end {
|
||||
let c = self.code[at];
|
||||
at = if c == '\\' {
|
||||
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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,654 @@
|
||||
pub mod languages;
|
||||
pub mod markdown;
|
||||
|
||||
pub use languages::{
|
||||
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Kind {
|
||||
Keyword,
|
||||
String,
|
||||
Literal,
|
||||
Comment,
|
||||
Metadata,
|
||||
Punctuation,
|
||||
Mark,
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
|
||||
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() {
|
||||
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)
|
||||
}
|
||||
|
||||
fn at_word_start(&self) -> bool {
|
||||
self.at == 0
|
||||
|| self.code[self.at - 1].is_whitespace()
|
||||
|| ";|&(".contains(self.code[self.at - 1])
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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 == '_'
|
||||
}
|
||||
|
||||
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[..]
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[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,637 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Both bounds apply -- whichever bites first -- because the two failure
|
||||
/// modes are different: a flood of short lines exhausts the count, and one
|
||||
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
|
||||
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
|
||||
/// only by bytes can be emptied by a single line.
|
||||
pub const DEFAULT_MAX_LINES: usize = 2000;
|
||||
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LogLine {
|
||||
pub seq: u64,
|
||||
/// Milliseconds since the unix epoch, from the app's own clock. The
|
||||
/// app's rather than the receiver's: a line is timestamped when it
|
||||
/// happened, and an upload can be minutes later or never.
|
||||
pub at_ms: u64,
|
||||
pub level: log::Level,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl LogLine {
|
||||
fn weight(&self) -> usize {
|
||||
self.target.len() + self.message.len() + 32
|
||||
}
|
||||
|
||||
/// `12:34:56.789 INFO iris::android: the message`, the shape a
|
||||
/// person skims. Time of day only -- the date is in the report's own
|
||||
/// header, and a ring never spans one.
|
||||
pub fn format(&self) -> String {
|
||||
format!(
|
||||
"{} {:<5} {}: {}",
|
||||
clock_time(self.at_ms),
|
||||
self.level,
|
||||
self.target,
|
||||
self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn clock_time(at_ms: u64) -> String {
|
||||
let ms = at_ms % 1000;
|
||||
let secs_of_day = (at_ms / 1000) % 86_400;
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}.{:03}",
|
||||
secs_of_day / 3600,
|
||||
(secs_of_day % 3600) / 60,
|
||||
secs_of_day % 60,
|
||||
ms
|
||||
)
|
||||
}
|
||||
|
||||
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
|
||||
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
|
||||
/// the app down for.
|
||||
pub fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inner {
|
||||
lines: VecDeque<LogLine>,
|
||||
bytes: usize,
|
||||
max_lines: usize,
|
||||
max_bytes: usize,
|
||||
next_seq: u64,
|
||||
dropped: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogRing(Arc<Mutex<Inner>>);
|
||||
|
||||
impl LogRing {
|
||||
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
|
||||
assert!(
|
||||
max_lines > 0 && max_bytes > 0,
|
||||
"a ring with no room holds nothing"
|
||||
);
|
||||
Self(Arc::new(Mutex::new(Inner {
|
||||
lines: VecDeque::new(),
|
||||
bytes: 0,
|
||||
max_lines,
|
||||
max_bytes,
|
||||
next_seq: 0,
|
||||
dropped: 0,
|
||||
})))
|
||||
}
|
||||
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
|
||||
}
|
||||
|
||||
/// A poisoned lock is a bug in a panicking logger, not a reason to
|
||||
/// take the app down a second time -- the ring is a diagnostic, and
|
||||
/// losing it must not be worse than the fault it was recording.
|
||||
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
|
||||
let mut guard = match self.0.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
f(&mut guard)
|
||||
}
|
||||
|
||||
pub fn push(&self, level: log::Level, target: &str, message: String) {
|
||||
self.with(|inner| {
|
||||
let line = LogLine {
|
||||
seq: inner.next_seq,
|
||||
at_ms: now_ms(),
|
||||
level,
|
||||
target: target.to_string(),
|
||||
message,
|
||||
};
|
||||
inner.next_seq += 1;
|
||||
inner.bytes += line.weight();
|
||||
inner.lines.push_back(line);
|
||||
// `!is_empty()` rather than `len() > 1`: one line larger than
|
||||
// the whole byte bound is kept, because dropping it would
|
||||
// leave the ring silently empty while lines were arriving.
|
||||
while inner.lines.len() > inner.max_lines
|
||||
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
|
||||
{
|
||||
if let Some(evicted) = inner.lines.pop_front() {
|
||||
inner.bytes -= evicted.weight();
|
||||
inner.dropped += 1;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Vec<LogLine> {
|
||||
self.with(|inner| inner.lines.iter().cloned().collect())
|
||||
}
|
||||
|
||||
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
|
||||
self.with(|inner| {
|
||||
let lines: Vec<LogLine> = inner
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.seq >= seq)
|
||||
.cloned()
|
||||
.collect();
|
||||
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
|
||||
(lines, next)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.with(|inner| inner.lines.len())
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
pub fn dropped(&self) -> u64 {
|
||||
self.with(|inner| inner.dropped)
|
||||
}
|
||||
|
||||
/// The sequence number of the newest line held, or `None` for a ring
|
||||
/// nothing has been written to.
|
||||
///
|
||||
/// What a reader needs to notice that this process **restarted**: the
|
||||
/// ring is in memory, so a new process starts again at zero, and a
|
||||
/// reader holding a cursor from the previous one would otherwise ask
|
||||
/// for lines after a number nothing will reach for hours and see
|
||||
/// nothing at all -- silently, which is worse than seeing the log
|
||||
/// begin again. Answering `None` rather than 0 for an empty ring is
|
||||
/// the same distinction [`Self::summary`] draws: "nothing has been
|
||||
/// logged" is not a sequence number.
|
||||
pub fn newest_seq(&self) -> Option<u64> {
|
||||
self.with(|inner| inner.lines.back().map(|line| line.seq))
|
||||
}
|
||||
|
||||
/// When the newest line was written, in unix milliseconds, or `None`
|
||||
/// for a ring nothing has been written to.
|
||||
pub fn last_at_ms(&self) -> Option<u64> {
|
||||
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
|
||||
}
|
||||
|
||||
pub fn to_text(&self) -> String {
|
||||
self.snapshot()
|
||||
.iter()
|
||||
.map(LogLine::format)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The newest `max_lines` lines, formatted, or `None` if the ring is
|
||||
/// locked at this instant.
|
||||
///
|
||||
/// For the one caller that must not block: **the panic hook**. A panic
|
||||
/// raised while this ring's own lock was held -- an allocation failing
|
||||
/// inside [`Self::push`], an assertion in a `log::Log` on the way here
|
||||
/// -- would deadlock the hook against the thread that is panicking,
|
||||
/// and the process would hang instead of aborting, with nothing
|
||||
/// written anywhere. Losing the context lines is the right trade
|
||||
/// against that, and `None` says which happened rather than looking
|
||||
/// like an empty log.
|
||||
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
|
||||
let guard = match self.0.try_lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||
Err(std::sync::TryLockError::WouldBlock) => return None,
|
||||
};
|
||||
let lines = &guard.lines;
|
||||
let from = lines.len().saturating_sub(max_lines);
|
||||
Some(
|
||||
lines
|
||||
.iter()
|
||||
.skip(from)
|
||||
.map(LogLine::format)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
/// One line for a diagnostics pane: how much is held, how much was
|
||||
/// dropped, and when the last line arrived. "no lines yet" is its own
|
||||
/// wording rather than a count of zero with a made-up time, because
|
||||
/// "nothing has been logged" and "logging is not running" would
|
||||
/// otherwise look the same.
|
||||
pub fn summary(&self) -> String {
|
||||
let (len, dropped, last) = self.with(|inner| {
|
||||
(
|
||||
inner.lines.len(),
|
||||
inner.dropped,
|
||||
inner.lines.back().map(|line| line.at_ms),
|
||||
)
|
||||
});
|
||||
match last {
|
||||
None => "app log: no lines yet".to_string(),
|
||||
Some(at) => {
|
||||
let dropped = if dropped > 0 {
|
||||
format!(", {dropped} dropped")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"app log: {len} lines held{dropped}, last {}",
|
||||
clock_time(at)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a target belongs to this app or to `iris` rather than to a
|
||||
/// dependency -- `starts_with` guarded by an
|
||||
/// exact match or a `::` so an unrelated crate that merely begins with the
|
||||
/// same letters (there is no such crate today, but the check should not
|
||||
/// rely on that) is never mistaken for one of ours.
|
||||
fn is_own_target(target: &str) -> bool {
|
||||
target == "iris"
|
||||
|| target.starts_with("iris::")
|
||||
|| target == "ai_app"
|
||||
|| target.starts_with("ai_app::")
|
||||
}
|
||||
|
||||
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
|
||||
/// asked for, applied once here rather than at each `debug!` call site:
|
||||
/// Info and above always ring, from anything, because a real warning or
|
||||
/// error from a dependency is worth keeping. Debug and Trace ring only
|
||||
/// from this app's own targets, and only while tracing is switched on --
|
||||
/// otherwise `naga::front`/`wgpu_core`/`jni` log at Debug unconditionally
|
||||
/// (the process logger's own level, set once at install and unrelated to
|
||||
/// tracing), which is what filled the ring with 1339 lines of it and
|
||||
/// dropped 4050 more before this existed. `iris`'s own Debug lines already
|
||||
/// self-gate on `iris::diagnostics::trace_enabled` at their call sites
|
||||
/// (commit 992c472); this is the backstop for lines this crate does not
|
||||
/// control.
|
||||
fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
|
||||
level <= log::Level::Info || (trace_enabled && is_own_target(target))
|
||||
}
|
||||
|
||||
pub struct RingLogger {
|
||||
ring: LogRing,
|
||||
inner: Box<dyn log::Log>,
|
||||
trace_enabled: fn() -> bool,
|
||||
}
|
||||
|
||||
impl RingLogger {
|
||||
pub fn new(ring: LogRing, inner: Box<dyn log::Log>, trace_enabled: fn() -> bool) -> Self {
|
||||
Self {
|
||||
ring,
|
||||
inner,
|
||||
trace_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for RingLogger {
|
||||
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
if ring_accepts(record.level(), record.target(), (self.trace_enabled)()) {
|
||||
self.ring
|
||||
.push(record.level(), record.target(), record.args().to_string());
|
||||
}
|
||||
if self.inner.enabled(record.metadata()) {
|
||||
self.inner.log(record);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
self.inner.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fails only if a logger is already installed, which is a programmer
|
||||
/// error (two initialisation paths) rather than a recoverable condition --
|
||||
/// the caller is named in the error so it is findable.
|
||||
pub fn install(
|
||||
ring: LogRing,
|
||||
inner: Box<dyn log::Log>,
|
||||
max_level: log::LevelFilter,
|
||||
trace_enabled: fn() -> bool,
|
||||
) -> Result<(), log::SetLoggerError> {
|
||||
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner, trace_enabled)))?;
|
||||
log::set_max_level(max_level);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// **A deliberate process-global, where this project's rules otherwise say
|
||||
/// pass context explicitly.** What is being modelled is already one: `log`
|
||||
/// has exactly one backend per process, set once, and every `log::info!`
|
||||
/// anywhere in the binary goes to it. A ring handed around as a parameter
|
||||
/// would be a *second* answer to "which lines exist" -- the report would
|
||||
/// show one ring while the logger filled another, and which one a caller
|
||||
/// got would depend on how far down the call tree it was. The tests above
|
||||
/// all use their own [`LogRing`], so nothing here needs this to be
|
||||
/// testable.
|
||||
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
|
||||
|
||||
pub fn process_ring() -> &'static LogRing {
|
||||
PROCESS_RING.get_or_init(LogRing::with_defaults)
|
||||
}
|
||||
|
||||
pub fn install_process_logger(
|
||||
inner: Box<dyn log::Log>,
|
||||
max_level: log::LevelFilter,
|
||||
trace_enabled: fn() -> bool,
|
||||
) -> Result<(), log::SetLoggerError> {
|
||||
install(process_ring().clone(), inner, max_level, trace_enabled)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Level;
|
||||
|
||||
fn fill(ring: &LogRing, count: usize) {
|
||||
for n in 0..count {
|
||||
ring.push(Level::Info, "test", format!("line {n}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_come_back_oldest_first() {
|
||||
let ring = LogRing::new(10, 1 << 20);
|
||||
fill(&ring, 3);
|
||||
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(text, ["line 0", "line 1", "line 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_line_bound_drops_the_oldest_and_says_how_many() {
|
||||
let ring = LogRing::new(3, 1 << 20);
|
||||
fill(&ring, 5);
|
||||
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
|
||||
assert_eq!(ring.len(), 3);
|
||||
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
|
||||
let ring = LogRing::new(1000, 300);
|
||||
for n in 0..10 {
|
||||
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
|
||||
}
|
||||
assert!(
|
||||
ring.len() < 10,
|
||||
"the byte bound evicted: {} held",
|
||||
ring.len()
|
||||
);
|
||||
assert!(ring.dropped() > 0);
|
||||
assert!(
|
||||
ring.snapshot().last().unwrap().message.starts_with('9'),
|
||||
"and it evicted from the old end"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
|
||||
let ring = LogRing::new(100, 64);
|
||||
ring.push(Level::Error, "t", "y".repeat(5000));
|
||||
assert_eq!(ring.len(), 1);
|
||||
assert_eq!(ring.dropped(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_numbers_only_increase_and_survive_eviction() {
|
||||
let ring = LogRing::new(2, 1 << 20);
|
||||
fill(&ring, 5);
|
||||
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
|
||||
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_returns_only_what_is_new_and_the_next_cursor() {
|
||||
let ring = LogRing::new(100, 1 << 20);
|
||||
fill(&ring, 3);
|
||||
let (first, cursor) = ring.since(0);
|
||||
assert_eq!(first.len(), 3);
|
||||
assert_eq!(cursor, 3);
|
||||
|
||||
let (none, cursor) = ring.since(cursor);
|
||||
assert!(none.is_empty(), "nothing new yet");
|
||||
assert_eq!(cursor, 3, "and the cursor does not move");
|
||||
|
||||
ring.push(Level::Warn, "test", "later".into());
|
||||
let (more, cursor) = ring.since(cursor);
|
||||
assert_eq!(more.len(), 1);
|
||||
assert_eq!(more[0].message, "later");
|
||||
assert_eq!(cursor, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
|
||||
let ring = LogRing::new(100, 1 << 20);
|
||||
assert_eq!(ring.newest_seq(), None, "an empty ring has no newest line");
|
||||
fill(&ring, 5);
|
||||
assert_eq!(ring.newest_seq(), Some(4));
|
||||
|
||||
let restarted = LogRing::new(100, 1 << 20);
|
||||
fill(&restarted, 1);
|
||||
assert_eq!(
|
||||
restarted.newest_seq(),
|
||||
Some(0),
|
||||
"a fresh ring starts again, which is exactly what a reader has to notice"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_does_not_consume() {
|
||||
let ring = LogRing::new(100, 1 << 20);
|
||||
fill(&ring, 2);
|
||||
let (sent, _) = ring.since(0);
|
||||
assert_eq!(sent.len(), 2);
|
||||
assert_eq!(ring.len(), 2, "the report still has them after an upload");
|
||||
assert_eq!(ring.to_text().lines().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_tail_text_gives_the_newest_lines_with_no_header() {
|
||||
let ring = LogRing::new(1000, 1 << 20);
|
||||
fill(&ring, 200);
|
||||
let tail = ring.try_tail_text(80).expect("nothing holds the lock");
|
||||
let lines: Vec<&str> = tail.lines().collect();
|
||||
assert_eq!(lines.len(), 80, "the cap, and no header: this is a file");
|
||||
assert!(lines[0].ends_with("line 120"), "{}", lines[0]);
|
||||
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
|
||||
let ring = LogRing::new(10, 1 << 20);
|
||||
fill(&ring, 3);
|
||||
let held = ring.0.lock().expect("fresh ring");
|
||||
assert_eq!(ring.try_tail_text(80), None);
|
||||
drop(held);
|
||||
assert!(ring.try_tail_text(80).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
|
||||
let ring = LogRing::with_defaults();
|
||||
assert_eq!(ring.summary(), "app log: no lines yet");
|
||||
assert_eq!(ring.last_at_ms(), None);
|
||||
assert!(ring.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_names_dropped_lines_only_when_there_are_some() {
|
||||
let ring = LogRing::new(2, 1 << 20);
|
||||
fill(&ring, 2);
|
||||
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
|
||||
fill(&ring, 2);
|
||||
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_formats_as_time_level_target_message() {
|
||||
let line = LogLine {
|
||||
seq: 0,
|
||||
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
|
||||
level: Level::Info,
|
||||
target: "iris::android".into(),
|
||||
message: "surface created".into(),
|
||||
}
|
||||
.format();
|
||||
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ring_logger_forwards_to_the_inner_logger() {
|
||||
use log::Log;
|
||||
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
|
||||
impl Log for Collect {
|
||||
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||
metadata.level() <= self.1
|
||||
}
|
||||
fn log(&self, record: &log::Record) {
|
||||
self.0.lock().unwrap().push(record.args().to_string());
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let ring = LogRing::with_defaults();
|
||||
let logger = RingLogger::new(
|
||||
ring.clone(),
|
||||
Box::new(Collect(seen.clone(), Level::Info)),
|
||||
|| true,
|
||||
);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("kept"))
|
||||
.level(Level::Info)
|
||||
.target("iris::test")
|
||||
.build(),
|
||||
);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("filtered"))
|
||||
.level(Level::Debug)
|
||||
.target("iris::test")
|
||||
.build(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
*seen.lock().unwrap(),
|
||||
["kept"],
|
||||
"the inner logger's own filter still applies"
|
||||
);
|
||||
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(
|
||||
held,
|
||||
["kept", "filtered"],
|
||||
"own-target debug still rings while tracing is on"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
|
||||
use log::Log;
|
||||
struct Discard;
|
||||
impl Log for Discard {
|
||||
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
fn log(&self, _: &log::Record) {}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
let ring = LogRing::with_defaults();
|
||||
let logger = RingLogger::new(ring.clone(), Box::new(Discard), || true);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("naga debug spam"))
|
||||
.level(Level::Debug)
|
||||
.target("naga::front")
|
||||
.build(),
|
||||
);
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("naga warning"))
|
||||
.level(Level::Warn)
|
||||
.target("wgpu_core::device")
|
||||
.build(),
|
||||
);
|
||||
|
||||
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||
assert_eq!(
|
||||
held,
|
||||
["naga warning"],
|
||||
"Info-and-above always rings; foreign Debug never does"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_accepts_is_own_target_debug_only_while_tracing() {
|
||||
assert!(
|
||||
ring_accepts(Level::Info, "wgpu_core::device", false),
|
||||
"Info+ from anything, tracing off"
|
||||
);
|
||||
assert!(
|
||||
ring_accepts(Level::Warn, "jni", true),
|
||||
"Info+ from anything, tracing on"
|
||||
);
|
||||
assert!(
|
||||
!ring_accepts(Level::Debug, "jni", true),
|
||||
"foreign Debug, tracing on: still excluded"
|
||||
);
|
||||
assert!(
|
||||
!ring_accepts(Level::Debug, "iris::sense", false),
|
||||
"own Debug, tracing off: excluded"
|
||||
);
|
||||
assert!(
|
||||
ring_accepts(Level::Debug, "iris::sense", true),
|
||||
"own Debug, tracing on: included"
|
||||
);
|
||||
assert!(
|
||||
ring_accepts(Level::Trace, "ai_app::api", true),
|
||||
"own Trace, tracing on: included"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_own_target_matches_the_crate_or_its_modules_only() {
|
||||
assert!(is_own_target("iris"));
|
||||
assert!(is_own_target("iris::sense"));
|
||||
assert!(is_own_target("ai_app"));
|
||||
assert!(is_own_target("ai_app::log_ring"));
|
||||
assert!(!is_own_target("iris_something_else"));
|
||||
assert!(!is_own_target("naga::front"));
|
||||
assert!(!is_own_target("jni"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag};
|
||||
|
||||
/// What a block is, for a renderer that wants to style or space blocks
|
||||
/// differently. `Other` is deliberately present rather than a panic or a
|
||||
/// silent fallback to `Paragraph`: markdown has more block kinds than this
|
||||
/// list and more get added, and a renderer treating an unknown one as
|
||||
/// prose is right, but it should be able to *tell* that is what it is
|
||||
/// doing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlockKind {
|
||||
Paragraph,
|
||||
Heading,
|
||||
Code,
|
||||
List,
|
||||
Table,
|
||||
Quote,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Block {
|
||||
pub kind: BlockKind,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
fn kind_of(tag: &Tag) -> BlockKind {
|
||||
match tag {
|
||||
Tag::Paragraph => BlockKind::Paragraph,
|
||||
Tag::Heading { .. } => BlockKind::Heading,
|
||||
Tag::CodeBlock(_) => BlockKind::Code,
|
||||
Tag::List(_) => BlockKind::List,
|
||||
Tag::Table(_) => BlockKind::Table,
|
||||
Tag::BlockQuote(_) => BlockKind::Quote,
|
||||
_ => BlockKind::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||
}
|
||||
|
||||
pub fn split_blocks(src: &str) -> Vec<Block> {
|
||||
let mut out: Vec<Block> = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut kind = BlockKind::Other;
|
||||
for (event, range) in Parser::new_ext(src, options()).into_offset_iter() {
|
||||
match event {
|
||||
Event::Start(tag) => {
|
||||
if depth == 0 {
|
||||
kind = kind_of(&tag);
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
Event::End(_) => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
push(&mut out, kind, &src[range]);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if depth == 0 {
|
||||
push(&mut out, BlockKind::Other, &src[range]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn push(out: &mut Vec<Block>, kind: BlockKind, source: &str) {
|
||||
let source = source.trim_end();
|
||||
if source.is_empty() {
|
||||
return;
|
||||
}
|
||||
out.push(Block {
|
||||
kind,
|
||||
source: source.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// How many leading blocks of `old` and `new` are identical -- what a
|
||||
/// caller may keep the laid-out widgets for. See the module doc for why
|
||||
/// this is a comparison rather than an assumption.
|
||||
pub fn common_prefix(old: &[Block], new: &[Block]) -> usize {
|
||||
old.iter().zip(new).take_while(|(a, b)| a == b).count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn kinds(src: &str) -> Vec<BlockKind> {
|
||||
split_blocks(src).into_iter().map(|b| b.kind).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_splits_into_its_top_level_blocks() {
|
||||
let src = "# Title\n\nFirst para.\n\n```rust\nfn main() {}\n```\n\n- a\n- b\n";
|
||||
assert_eq!(
|
||||
kinds(src),
|
||||
vec![
|
||||
BlockKind::Heading,
|
||||
BlockKind::Paragraph,
|
||||
BlockKind::Code,
|
||||
BlockKind::List
|
||||
]
|
||||
);
|
||||
let blocks = split_blocks(src);
|
||||
assert_eq!(blocks[1].source, "First para.");
|
||||
assert_eq!(blocks[2].source, "```rust\nfn main() {}\n```");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_input_has_no_blocks() {
|
||||
assert!(split_blocks("").is_empty());
|
||||
assert!(split_blocks(" \n\n ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
|
||||
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
|
||||
let after = split_blocks("# Title\n\nFirst para.\n\nSecond paragraph now.");
|
||||
assert_eq!(common_prefix(&before, &after), 2);
|
||||
assert_eq!(before.len(), 3);
|
||||
assert_eq!(after.len(), 3);
|
||||
assert_ne!(before[2], after[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
|
||||
let before = split_blocks("First para.\n\nSecond para.");
|
||||
let after = split_blocks("First para.\n\nSecond para.\n\nThird");
|
||||
assert_eq!(common_prefix(&before, &after), 2);
|
||||
assert_eq!(after.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unterminated_fence_is_one_block_while_it_streams() {
|
||||
for src in [
|
||||
"Here:\n\n```rust\n",
|
||||
"Here:\n\n```rust\nfn main() {\n",
|
||||
"Here:\n\n```rust\nfn main() {\n println!(\"hi\");\n",
|
||||
] {
|
||||
assert_eq!(
|
||||
kinds(src),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code],
|
||||
"{src:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
|
||||
let before = split_blocks("Not a heading\n\nsecond");
|
||||
let after = split_blocks("Not a heading\n\nsecond\n---");
|
||||
assert_eq!(before[1].kind, BlockKind::Paragraph);
|
||||
assert_eq!(after[1].kind, BlockKind::Heading);
|
||||
assert_eq!(
|
||||
common_prefix(&before, &after),
|
||||
1,
|
||||
"the rewritten block must not be reported as keepable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thematic_break_is_its_own_block() {
|
||||
assert_eq!(
|
||||
kinds("one\n\n---\n\ntwo"),
|
||||
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_transcripts_own_block_shapes_survive_a_split() {
|
||||
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
|
||||
assert_eq!(
|
||||
kinds(fence_with_blanks),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code, BlockKind::Paragraph],
|
||||
"a blank line inside a fence is not a block boundary"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("```\n---\n```"),
|
||||
vec![BlockKind::Code],
|
||||
"a thematic break inside a fence is code, not a break"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("- a\n - a1\n - a2\n- b"),
|
||||
vec![BlockKind::List],
|
||||
"a nested list is one top-level block"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("## Heading\n```sh\nls\n```"),
|
||||
vec![BlockKind::Heading, BlockKind::Code],
|
||||
"a fence directly under a heading, with no blank line"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("| a | b |\n|---|---|\n| 1 | 2 |"),
|
||||
vec![BlockKind::Table]
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("> quoted\n> more\n\nplain"),
|
||||
vec![BlockKind::Quote, BlockKind::Paragraph]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
|
||||
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
|
||||
let mut prev = Vec::new();
|
||||
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
|
||||
let now = split_blocks(&full[..end]);
|
||||
let common = common_prefix(&prev, &now);
|
||||
assert!(
|
||||
prev.is_empty() || common + 1 >= prev.len(),
|
||||
"at {end} bytes the split rewrote block {common} of {}, not just the last one:\n before={prev:#?}\nafter={now:#?}",
|
||||
prev.len()
|
||||
);
|
||||
prev = now;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
|
||||
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
|
||||
let blocks = split_blocks(src);
|
||||
assert_eq!(
|
||||
blocks.iter().map(|b| b.kind).collect::<Vec<_>>(),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code]
|
||||
);
|
||||
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
|
||||
let before = split_blocks("Text.\n\n```\ncode\n");
|
||||
let after = split_blocks("Text.\n\n```\ncode\n```");
|
||||
assert_eq!(before.len(), after.len());
|
||||
assert_eq!(common_prefix(&before, &after), 1);
|
||||
assert_ne!(before[1], after[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
pub mod ansi;
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod durations;
|
||||
pub mod event_stream;
|
||||
pub mod highlight;
|
||||
pub mod log_ring;
|
||||
pub mod markdown_blocks;
|
||||
pub mod notifications;
|
||||
pub mod sse;
|
||||
pub mod text_cap;
|
||||
pub mod tool_summary;
|
||||
pub mod transcript_cache;
|
||||
pub mod transcript_fold;
|
||||
pub mod transcript_source;
|
||||
|
||||
pub use event_model::*;
|
||||
@@ -0,0 +1,149 @@
|
||||
//! `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::client::sse`]) and the wire shape ([`SessionNotification`],
|
||||
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
||||
//! `Notification`/`NotificationKind`).
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::client::api::{ApiError, Transport};
|
||||
use crate::client::sse::SseReader;
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[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::client::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,102 @@
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SseReader {
|
||||
data: String,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
impl SseReader {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
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()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/// The default bound on a verbatim block -- a tool call's input or its
|
||||
/// output. Short, because this text is a machine's and the reader is
|
||||
/// looking for one line of it.
|
||||
pub const VERBATIM_LINES: usize = 80;
|
||||
pub const VERBATIM_BYTES: usize = 4096;
|
||||
|
||||
pub const MESSAGE_LINES: usize = 200;
|
||||
pub const MESSAGE_BYTES: usize = 16 * 1024;
|
||||
|
||||
const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0);
|
||||
const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0);
|
||||
|
||||
/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line
|
||||
/// count it was cut *from*; `None` when the whole of it fits.
|
||||
pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> {
|
||||
debug_assert!(
|
||||
max_lines > 0 && max_bytes > 0,
|
||||
"a cap of nothing shows an empty block and a 'Show all' for every value there is",
|
||||
);
|
||||
let by_lines = text
|
||||
.char_indices()
|
||||
.filter(|(_, c)| *c == '\n')
|
||||
.nth(max_lines - 1)
|
||||
.map(|(i, _)| i);
|
||||
let by_bytes = (text.len() > max_bytes).then(|| {
|
||||
let mut end = max_bytes;
|
||||
// Back up to a character boundary: a cut inside a multi-byte
|
||||
// character panics on the slice below, and a transcript is full of
|
||||
// them.
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
end
|
||||
});
|
||||
let cut = match (by_lines, by_bytes) {
|
||||
(Some(a), Some(b)) => a.min(b),
|
||||
(a, b) => a.or(b)?,
|
||||
};
|
||||
Some((&text[..cut], text.lines().count()))
|
||||
}
|
||||
|
||||
pub fn show_all_label(lines: usize) -> String {
|
||||
format!("Show all {lines} lines")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn text_under_both_bounds_is_not_cut() {
|
||||
assert_eq!(cut("one\ntwo\nthree", 80, 4096), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_line_bound_cuts_at_a_line_boundary() {
|
||||
let text = "a\nb\nc\nd\n";
|
||||
let (shown, lines) = cut(text, 2, 4096).expect("four lines is over a bound of two");
|
||||
assert_eq!(shown, "a\nb");
|
||||
assert_eq!(
|
||||
lines, 4,
|
||||
"the count is the whole text's, not the shown part's"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_byte_bound_cuts_one_long_line() {
|
||||
let text = "x".repeat(5000);
|
||||
let (shown, lines) = cut(&text, 80, 4096).expect("5000 bytes is over a bound of 4096");
|
||||
assert_eq!(shown.len(), 4096);
|
||||
assert_eq!(lines, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tighter_of_the_two_bounds_wins() {
|
||||
let text = "aaaa\n".repeat(100);
|
||||
let (shown, _) = cut(&text, 80, 100).expect("over both");
|
||||
assert_eq!(shown.len(), 100, "the byte bound is the tighter one here");
|
||||
let (shown, _) = cut(&text, 4, 4096).expect("over the line bound");
|
||||
assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() {
|
||||
let text = "é".repeat(100);
|
||||
let (shown, _) = cut(&text, 80, 11).expect("200 bytes is over a bound of 11");
|
||||
assert_eq!(
|
||||
shown,
|
||||
"é".repeat(5),
|
||||
"11 bytes lands mid-character; 10 is the cut"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use crate::client::durations::format_millis_text;
|
||||
use crate::client::highlight::Language;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ToolInput {
|
||||
pub subject: Option<String>,
|
||||
pub language: Option<Language>,
|
||||
pub description: Option<String>,
|
||||
/// How long the call may take, in the largest units it fits. Shown
|
||||
/// apart because it is a limit on the call rather than part of what
|
||||
/// the call does.
|
||||
pub timeout: Option<String>,
|
||||
/// Everything else, as `name: value` lines. Never dropped.
|
||||
pub rest: Vec<String>,
|
||||
}
|
||||
|
||||
impl ToolInput {
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
self.description
|
||||
.as_deref()
|
||||
.or(self.subject.as_deref())
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
|
||||
("Bash", "command", Some(Language::Shell)),
|
||||
("Read", "file_path", None),
|
||||
("Write", "file_path", None),
|
||||
("Edit", "file_path", None),
|
||||
("Glob", "pattern", None),
|
||||
("Grep", "pattern", None),
|
||||
("WebFetch", "url", None),
|
||||
];
|
||||
|
||||
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
|
||||
|
||||
/// One function rather than two, because the same coercion decides both
|
||||
/// what a subject reads as and what a leftover field's value reads as, and
|
||||
/// two copies would eventually disagree about a number.
|
||||
fn as_text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<&Value>) -> Option<String> {
|
||||
let text = as_text(value?);
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
|
||||
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
|
||||
return ToolInput {
|
||||
rest: match input.trim().is_empty() {
|
||||
true => Vec::new(),
|
||||
false => vec![input.to_string()],
|
||||
},
|
||||
..ToolInput::default()
|
||||
};
|
||||
};
|
||||
parse_object(tool, &json)
|
||||
}
|
||||
|
||||
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
|
||||
let (subject_key, language) = SUBJECTS
|
||||
.iter()
|
||||
.find(|(name, ..)| *name == tool)
|
||||
.map(|(_, key, language)| (Some(*key), *language))
|
||||
.unwrap_or((None, None));
|
||||
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
|
||||
let description = DESCRIPTIONS
|
||||
.iter()
|
||||
.find_map(|key| non_blank(json.get(*key)));
|
||||
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
|
||||
|
||||
let mut keys: Vec<&String> = json
|
||||
.keys()
|
||||
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
|
||||
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
|
||||
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
|
||||
.collect();
|
||||
keys.sort();
|
||||
let rest = keys
|
||||
.into_iter()
|
||||
.map(|key| format!("{key}: {}", as_text(&json[key])))
|
||||
.collect();
|
||||
|
||||
ToolInput {
|
||||
subject,
|
||||
language,
|
||||
description,
|
||||
timeout,
|
||||
rest,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_tool_in_the_table_has_its_own_subject() {
|
||||
let cases = [
|
||||
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
|
||||
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
|
||||
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
|
||||
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
|
||||
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
|
||||
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
|
||||
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
|
||||
];
|
||||
for (tool, input, expected) in cases {
|
||||
let parsed = parse_tool_input(tool, input);
|
||||
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
|
||||
assert_eq!(parsed.title(), Some(expected), "{tool}");
|
||||
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
|
||||
}
|
||||
assert_eq!(
|
||||
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
|
||||
Some(Language::Shell),
|
||||
"a Bash command is shell, and is the one row that names a language"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tools_own_description_is_what_the_one_line_says() {
|
||||
let parsed = parse_tool_input(
|
||||
"Bash",
|
||||
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
|
||||
);
|
||||
assert_eq!(parsed.title(), Some("Run the iris tests"));
|
||||
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
|
||||
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
|
||||
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
|
||||
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
|
||||
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_not_drawn_elsewhere_is_still_shown() {
|
||||
let parsed = parse_tool_input(
|
||||
"Edit",
|
||||
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.rest,
|
||||
vec![
|
||||
"new_string: y".to_string(),
|
||||
"old_string: x".to_string(),
|
||||
"replace_all: true".to_string(),
|
||||
],
|
||||
"sorted, and a non-string value written as JSON"
|
||||
);
|
||||
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
|
||||
assert_eq!(unknown.subject, None);
|
||||
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_that_is_not_an_object_is_still_the_input() {
|
||||
assert_eq!(
|
||||
parse_tool_input("Bash", "just a string").rest,
|
||||
vec!["just a string".to_string()]
|
||||
);
|
||||
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
|
||||
assert_eq!(parse_tool_input("Bash", "").title(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
|
||||
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
|
||||
assert_eq!(parsed.subject, None);
|
||||
assert_eq!(parsed.title(), None);
|
||||
assert_eq!(
|
||||
parsed.rest,
|
||||
vec!["command: ".to_string(), "other: 1".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,479 @@
|
||||
use event_model::SeqEvent;
|
||||
|
||||
use crate::client::api::{ApiClient, ApiError, Transport};
|
||||
use crate::client::event_stream::{self, StreamItem};
|
||||
use crate::client::transcript_cache::SessionCache;
|
||||
|
||||
/// The server's own default page size, named here because the cached
|
||||
/// opening has to be the same size as the fetched one -- a reader must not
|
||||
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
|
||||
/// in the Kotlin original).
|
||||
pub const OPENING_WINDOW: u32 = 80;
|
||||
|
||||
/// A transcript-line parse failure, told apart from [`ApiError`] so a
|
||||
/// caller can tell "the server is unreachable" from "the server (or this
|
||||
/// phone's own disk) sent something this build cannot read" -- the two
|
||||
/// mean different things to a reader (retry, versus a build that is
|
||||
/// behind).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParseError(pub String);
|
||||
|
||||
impl std::fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PageError {
|
||||
Api(ApiError),
|
||||
Parse(ParseError),
|
||||
}
|
||||
|
||||
impl From<ApiError> for PageError {
|
||||
fn from(e: ApiError) -> Self {
|
||||
Self::Api(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParseError> for PageError {
|
||||
fn from(e: ParseError) -> Self {
|
||||
Self::Parse(e)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum OlderPage {
|
||||
Events(Vec<SeqEvent>),
|
||||
NothingLoaded,
|
||||
}
|
||||
|
||||
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
||||
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
||||
}
|
||||
|
||||
pub struct TranscriptSource<T: Transport> {
|
||||
api: ApiClient<T>,
|
||||
session_id: String,
|
||||
pub cache: SessionCache,
|
||||
}
|
||||
|
||||
impl<T: Transport> TranscriptSource<T> {
|
||||
pub fn new(api: ApiClient<T>, session_id: impl Into<String>, cache: SessionCache) -> Self {
|
||||
Self {
|
||||
api,
|
||||
session_id: session_id.into(),
|
||||
cache,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached opening window, or `None` when there is nothing usable
|
||||
/// to draw.
|
||||
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
|
||||
self.cache.tail()?;
|
||||
let lines = self.cache.newest(limit);
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match lines.iter().map(|l| parse_line(l)).collect() {
|
||||
Ok(events) => Some(events),
|
||||
Err(ParseError(_)) => {
|
||||
self.cache.purge();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A caller must not resume a live stream from a cached seq unless it
|
||||
/// is the same conversation: a transcript is append-only in ordinary
|
||||
/// use, but the file backing it can be replaced or truncated (a
|
||||
/// sandbox re-seeded with the same ids, a backup restored, a session
|
||||
/// re-imported), and the server's catch-up on such a file would hand
|
||||
/// this phone a continuation of a *different* conversation, spliced
|
||||
/// onto the cached one with no seam. Caught with one request of a few
|
||||
/// hundred bytes.
|
||||
///
|
||||
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
|
||||
/// server not being askable, which is neither: the cached rows stay
|
||||
/// on screen and the caller tries again on its own reconnect schedule.
|
||||
pub fn probe(&self) -> Result<bool, ApiError> {
|
||||
let Some(tail) = self.cache.tail() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let page = self.api.fetch_transcript_lines(
|
||||
&self.session_id,
|
||||
Some(tail.seq + 1),
|
||||
1,
|
||||
false,
|
||||
None,
|
||||
)?;
|
||||
let matches = page.len() == 1
|
||||
&& parse_line(&tail.line)
|
||||
.map(|cached| cached == page[0].1)
|
||||
.unwrap_or(false);
|
||||
if !matches {
|
||||
self.cache.purge();
|
||||
}
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
|
||||
let page =
|
||||
self.api
|
||||
.fetch_transcript_lines(&self.session_id, None, OPENING_WINDOW, false, None)?;
|
||||
for (line, event) in &page {
|
||||
self.cache.append(line, event.seq);
|
||||
}
|
||||
self.cache.flush();
|
||||
Ok(page.into_iter().map(|(_, event)| event).collect())
|
||||
}
|
||||
|
||||
/// The page before `before`: from the cache when it holds it,
|
||||
/// otherwise from the server bounded by what the cache already has.
|
||||
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
|
||||
if before == 0 {
|
||||
return Ok(OlderPage::NothingLoaded);
|
||||
}
|
||||
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
|
||||
let events: Vec<SeqEvent> = lines
|
||||
.iter()
|
||||
.map(|l| parse_line(l).map_err(PageError::from))
|
||||
.collect::<Result<_, _>>()?;
|
||||
return Ok(OlderPage::Events(events));
|
||||
}
|
||||
let after = self.cache.covered_up_to(before).map(|v| v - 1);
|
||||
let page = self.api.fetch_transcript_lines(
|
||||
&self.session_id,
|
||||
Some(before),
|
||||
limit,
|
||||
coalesce,
|
||||
after,
|
||||
)?;
|
||||
if let Some((_, first_event)) = page.first() {
|
||||
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
|
||||
self.cache
|
||||
.store_page(&lines, first_event.seq, before, coalesce);
|
||||
}
|
||||
Ok(OlderPage::Events(
|
||||
page.into_iter().map(|(_, event)| event).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Before, so that an event held back for a reader who is scrolled
|
||||
/// away is already on disk -- what the cache holds is what the server
|
||||
/// sent, not what a screen has got round to drawing. Flushed on each
|
||||
/// status change, which is a turn's boundary and the granularity a
|
||||
/// crash may as well lose, and once more when the stream ends.
|
||||
pub fn follow(
|
||||
&self,
|
||||
after: u64,
|
||||
mut on_item: impl FnMut(StreamItem) -> bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let cache = &self.cache;
|
||||
let result = event_stream::follow_session_events(
|
||||
self.api.transport(),
|
||||
&self.session_id,
|
||||
after,
|
||||
|item| {
|
||||
if let StreamItem::Event { raw, event } = &item {
|
||||
cache.append(raw, event.seq);
|
||||
if matches!(event.event, event_model::Event::Status { .. }) {
|
||||
cache.flush();
|
||||
}
|
||||
}
|
||||
on_item(item)
|
||||
},
|
||||
);
|
||||
cache.flush();
|
||||
result
|
||||
}
|
||||
|
||||
/// Leaves the cache with everything it was given -- called once a
|
||||
/// caller is done with this source, mirroring the Kotlin `close`'s
|
||||
/// final flush (that method's stream cancellation itself is the
|
||||
/// runtime concern the module doc says is not ported here).
|
||||
pub fn close(&self) {
|
||||
self.cache.flush();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client::api::{Body, RawResponse};
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ScriptedTransport {
|
||||
responses: Mutex<VecDeque<(u16, String)>>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ScriptedTransport {
|
||||
fn respond(&self, status: u16, body: impl Into<String>) {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back((status, body.into()));
|
||||
}
|
||||
|
||||
fn call_count(&self) -> usize {
|
||||
self.calls.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for ScriptedTransport {
|
||||
fn request(
|
||||
&self,
|
||||
_method: &str,
|
||||
path: &str,
|
||||
_body: Option<Body>,
|
||||
) -> Result<RawResponse, ApiError> {
|
||||
self.calls.lock().unwrap().push(path.to_string());
|
||||
let (status, body) = self
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| panic!("ScriptedTransport got an unscripted request: {path}"));
|
||||
Ok(RawResponse {
|
||||
status,
|
||||
body: body.into_bytes(),
|
||||
})
|
||||
}
|
||||
|
||||
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||
self.calls.lock().unwrap().push(path.to_string());
|
||||
let (_, body) = self
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| {
|
||||
panic!("ScriptedTransport got an unscripted stream request: {path}")
|
||||
});
|
||||
Ok(Box::new(std::io::Cursor::new(body.into_bytes())))
|
||||
}
|
||||
}
|
||||
|
||||
fn source(
|
||||
transport: ScriptedTransport,
|
||||
cache_root: &std::path::Path,
|
||||
) -> TranscriptSource<ScriptedTransport> {
|
||||
let api = ApiClient::new(transport);
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(cache_root).session("s1");
|
||||
TranscriptSource::new(api, "s1", cache)
|
||||
}
|
||||
|
||||
fn status_line(seq: u64) -> String {
|
||||
format!(r#"{{"seq":{seq},"ts":1.0,"type":"status","state":"idle"}}"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cold_cache_has_no_opening_and_fetches_from_the_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
|
||||
assert_eq!(source.cached_opening(80), None);
|
||||
let opening = source.fetch_opening().unwrap();
|
||||
assert_eq!(opening.len(), 1);
|
||||
assert_eq!(opening[0].seq, 1);
|
||||
assert!(source.cache.tail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_matching_the_cached_tail_leaves_the_cache_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(200, format!("[{}]", status_line(1)));
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(source2.probe().unwrap());
|
||||
assert!(source2.cache.tail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_mismatching_the_cached_tail_purges_the_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
|
||||
transport2.respond(200, format!("[{different}]"));
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(!source2.probe().unwrap());
|
||||
assert!(source2.cache.tail().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_finding_no_server_leaves_the_cache_untouched() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(500, "server on fire");
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(source2.probe().is_err());
|
||||
assert!(
|
||||
source2.cache.tail().is_some(),
|
||||
"an unreachable server must not be treated as a mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paging_before_the_first_event_makes_no_request_at_all() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = source(transport, dir.path());
|
||||
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
|
||||
assert_eq!(source.api.transport().call_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_already_covered_by_the_cache_never_reaches_the_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{},{}]", status_line(1), status_line(2)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let calls_before = source.api.transport().call_count();
|
||||
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
|
||||
panic!("a cursor of 2 is a real question about the conversation");
|
||||
};
|
||||
assert_eq!(page.len(), 1);
|
||||
assert_eq!(page[0].seq, 1);
|
||||
assert_eq!(
|
||||
source.api.transport().call_count(),
|
||||
calls_before,
|
||||
"a cache hit must not touch the network"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(5)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(200, format!("[{}]", status_line(3)));
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
source2.page(5, 10, true).unwrap();
|
||||
assert_eq!(
|
||||
source2.api.transport().calls.lock().unwrap()[0],
|
||||
"/sessions/s1/transcript?limit=10&before=5&coalesce=true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let lines: Vec<String> = (3..6).map(status_line).collect();
|
||||
assert!(cache.store_page(&lines, 3, 6, true));
|
||||
cache.append(&status_line(6), 6);
|
||||
cache.append(&status_line(7), 7);
|
||||
cache.flush();
|
||||
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(9)));
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
source.page(10, 10, true).unwrap();
|
||||
assert_eq!(
|
||||
source.api.transport().calls.lock().unwrap()[0],
|
||||
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
|
||||
"the fetch must stop one seq below where this phone's copy ends"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(500, "server on fire");
|
||||
let source = source(transport, dir.path());
|
||||
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
cache.store_page(
|
||||
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
|
||||
3,
|
||||
4,
|
||||
true,
|
||||
);
|
||||
cache.append(&status_line(4), 4);
|
||||
cache.flush();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
|
||||
assert_eq!(
|
||||
source.api.transport().call_count(),
|
||||
0,
|
||||
"a cache hit that cannot be read must not fall through to the server unnoticed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
cache.append("not json at all", 1);
|
||||
cache.flush();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
assert_eq!(source.cached_opening(80), None);
|
||||
assert!(
|
||||
source.cache.tail().is_none(),
|
||||
"a damaged line purges the cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_writes_events_to_the_cache_before_the_caller_sees_them() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("{}\n\n", sse_frame(&status_line(1))));
|
||||
let source = source(transport, dir.path());
|
||||
let mut seen = Vec::new();
|
||||
source
|
||||
.follow(0, |item| {
|
||||
if let StreamItem::Event { event, .. } = item {
|
||||
seen.push(event.seq);
|
||||
}
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(seen, vec![1]);
|
||||
assert_eq!(source.cache.tail().unwrap().seq, 1);
|
||||
}
|
||||
|
||||
fn sse_frame(data: &str) -> String {
|
||||
format!("data:{data}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use crate::client::api::{ApiClient, SessionSummary, UreqTransport};
|
||||
use crate::client::event_stream::{StreamItem, follow_session_events};
|
||||
use crate::client::transcript_fold::{
|
||||
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
|
||||
};
|
||||
use event_model::SeqEvent;
|
||||
use iris::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const LIST_WIDTH: f32 = 260.0;
|
||||
|
||||
enum AppEvent {
|
||||
Sessions(Result<Vec<SessionSummary>, String>),
|
||||
TranscriptLoaded {
|
||||
session_id: String,
|
||||
generation: u64,
|
||||
result: Result<Vec<TranscriptItem>, String>,
|
||||
},
|
||||
StreamEvent {
|
||||
session_id: String,
|
||||
generation: u64,
|
||||
event: SeqEvent,
|
||||
},
|
||||
StreamEnded {
|
||||
session_id: String,
|
||||
generation: u64,
|
||||
message: Option<String>,
|
||||
},
|
||||
SendFailed(String),
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
DefaultApp::<Client>::run();
|
||||
}
|
||||
|
||||
#[derive(DefaultUiState)]
|
||||
struct Client {
|
||||
ui_state: DefaultUiState,
|
||||
api: Arc<ApiClient<UreqTransport>>,
|
||||
stream_transport: Arc<UreqTransport>,
|
||||
proxy: Proxy<AppEvent>,
|
||||
sessions: Vec<SessionSummary>,
|
||||
selected: Option<String>,
|
||||
items: Vec<TranscriptItem>,
|
||||
list_ptr: WeakWidget<WidgetPtr>,
|
||||
transcript_ptr: WeakWidget<WidgetPtr>,
|
||||
screen: Option<crate::ui::TranscriptScreen>,
|
||||
generation: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl DefaultAppState for Client {
|
||||
type Event = AppEvent;
|
||||
|
||||
fn new(
|
||||
mut ui_state: DefaultUiState,
|
||||
rsc: &mut DefaultRsc<Self>,
|
||||
proxy: Proxy<AppEvent>,
|
||||
) -> Self {
|
||||
let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| {
|
||||
eprintln!("desktop-app: {e}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
let build_transport =
|
||||
|| UreqTransport::new(server.base_url(), server.token.clone(), &ca_pem);
|
||||
let (rest_transport, stream_transport) = build_transport()
|
||||
.and_then(|rest| build_transport().map(|stream| (rest, stream)))
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"desktop-app: couldn't set up TLS to {}: {e}",
|
||||
server.base_url()
|
||||
);
|
||||
std::process::exit(1);
|
||||
});
|
||||
let api = Arc::new(ApiClient::new(rest_transport));
|
||||
let stream_transport = Arc::new(stream_transport);
|
||||
|
||||
let list_ptr = WidgetPtr::new().add(rsc);
|
||||
let transcript_ptr = WidgetPtr::new().add(rsc);
|
||||
let loading = placeholder(rsc, "Loading sessions...");
|
||||
transcript_ptr(rsc).set(loading);
|
||||
|
||||
(list_ptr.width(LIST_WIDTH), transcript_ptr.width(rest(1)))
|
||||
.span(Dir::RIGHT)
|
||||
.set_root(rsc, &mut ui_state);
|
||||
|
||||
let client = Self {
|
||||
ui_state,
|
||||
api,
|
||||
stream_transport,
|
||||
proxy,
|
||||
sessions: Vec::new(),
|
||||
selected: None,
|
||||
items: Vec::new(),
|
||||
list_ptr,
|
||||
transcript_ptr,
|
||||
screen: None,
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
};
|
||||
client.spawn_fetch_sessions();
|
||||
client
|
||||
}
|
||||
|
||||
fn event(&mut self, event: AppEvent, rsc: &mut DefaultRsc<Self>, _render: &mut UiRenderState) {
|
||||
match event {
|
||||
AppEvent::Sessions(Ok(sessions)) => {
|
||||
self.sessions = sessions;
|
||||
self.rebuild_list(rsc);
|
||||
if self.selected.is_none() {
|
||||
self.show_message(rsc, "Select a session.");
|
||||
}
|
||||
}
|
||||
AppEvent::Sessions(Err(message)) => {
|
||||
self.show_message(rsc, &format!("Couldn't list sessions: {message}"));
|
||||
}
|
||||
AppEvent::TranscriptLoaded {
|
||||
session_id,
|
||||
generation,
|
||||
result,
|
||||
} => {
|
||||
if self.current(&session_id, generation) {
|
||||
match result {
|
||||
Ok(items) => {
|
||||
self.items = items;
|
||||
self.rebuild_transcript(rsc);
|
||||
}
|
||||
Err(message) => {
|
||||
self.show_message(
|
||||
rsc,
|
||||
&format!("Couldn't load {session_id}: {message}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::StreamEvent {
|
||||
session_id,
|
||||
generation,
|
||||
event,
|
||||
} => {
|
||||
if self.current(&session_id, generation) {
|
||||
let old_items = self.items.clone();
|
||||
self.items = fold_event(&self.items, &event);
|
||||
match &self.screen {
|
||||
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||
None => self.rebuild_transcript(rsc),
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::StreamEnded {
|
||||
session_id,
|
||||
generation,
|
||||
message: Some(message),
|
||||
} => {
|
||||
if self.current(&session_id, generation) {
|
||||
eprintln!("desktop-app: {session_id}'s live connection ended: {message}");
|
||||
}
|
||||
}
|
||||
AppEvent::StreamEnded { .. } => {}
|
||||
AppEvent::SendFailed(message) => {
|
||||
eprintln!("desktop-app: couldn't send: {message}");
|
||||
}
|
||||
}
|
||||
self.ui_state.window.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn current(&self, session_id: &str, generation: u64) -> bool {
|
||||
self.selected.as_deref() == Some(session_id)
|
||||
&& self.generation.load(Ordering::SeqCst) == generation
|
||||
}
|
||||
|
||||
fn show_message(&mut self, rsc: &mut DefaultRsc<Self>, message: &str) {
|
||||
let widget = placeholder(rsc, message);
|
||||
(self.transcript_ptr)(rsc).set(widget);
|
||||
}
|
||||
|
||||
fn spawn_fetch_sessions(&self) {
|
||||
let api = self.api.clone();
|
||||
let proxy = self.proxy.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = api.fetch_sessions().map_err(|e| e.to_string());
|
||||
let _ = proxy.send_event(AppEvent::Sessions(result));
|
||||
});
|
||||
}
|
||||
|
||||
fn rebuild_list(&mut self, rsc: &mut DefaultRsc<Self>) {
|
||||
let list = Span::empty(Dir::DOWN).gap(2).add(rsc);
|
||||
for session in &self.sessions {
|
||||
let selected = self.selected.as_deref() == Some(session.id.as_str());
|
||||
let row = session_row(rsc, session, selected);
|
||||
list(rsc).push(row);
|
||||
}
|
||||
let tree = list
|
||||
.background(rect(Srgba8::rgb(24, 24, 28)))
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
(self.list_ptr)(rsc).set(tree);
|
||||
}
|
||||
|
||||
fn select_session(&mut self, rsc: &mut DefaultRsc<Self>, session_id: String) {
|
||||
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.selected = Some(session_id.clone());
|
||||
self.items.clear();
|
||||
self.screen = None;
|
||||
self.rebuild_list(rsc);
|
||||
self.show_message(rsc, "Loading transcript...");
|
||||
|
||||
let api = self.api.clone();
|
||||
let stream_transport = self.stream_transport.clone();
|
||||
let proxy = self.proxy.clone();
|
||||
let live_generation = self.generation.clone();
|
||||
std::thread::spawn(move || {
|
||||
let page: Result<Vec<serde_json::Value>, String> = api
|
||||
.fetch_transcript_page(&session_id, None, 200, true)
|
||||
.map_err(|e| e.to_string());
|
||||
let after = page
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|values| raw_seq(values.last()?))
|
||||
.unwrap_or(0);
|
||||
let result = page.and_then(|values| fold_page(&values));
|
||||
let _ = proxy.send_event(AppEvent::TranscriptLoaded {
|
||||
session_id: session_id.clone(),
|
||||
generation,
|
||||
result,
|
||||
});
|
||||
|
||||
let stop = || live_generation.load(Ordering::SeqCst) != generation;
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
let outcome =
|
||||
follow_session_events(&*stream_transport, &session_id, after, |item| match item {
|
||||
StreamItem::Open | StreamItem::Reset => !stop(),
|
||||
StreamItem::Event { event, .. } => {
|
||||
if stop() {
|
||||
return false;
|
||||
}
|
||||
let _ = proxy.send_event(AppEvent::StreamEvent {
|
||||
session_id: session_id.clone(),
|
||||
generation,
|
||||
event,
|
||||
});
|
||||
true
|
||||
}
|
||||
});
|
||||
let _ = proxy.send_event(AppEvent::StreamEnded {
|
||||
session_id,
|
||||
generation,
|
||||
message: outcome.err().map(|e| e.to_string()),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn send_message(&mut self, session_id: String, text: String) {
|
||||
let api = self.api.clone();
|
||||
let proxy = self.proxy.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = api.send_message(&session_id, &text, &[]) {
|
||||
let _ = proxy.send_event(AppEvent::SendFailed(e.to_string()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn rebuild_transcript(&mut self, rsc: &mut DefaultRsc<Self>) {
|
||||
let in_progress = self
|
||||
.screen
|
||||
.as_ref()
|
||||
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
|
||||
.filter(|t| !t.is_empty());
|
||||
|
||||
let rows = group_tool_runs(&self.items);
|
||||
let (screen, tree) = crate::ui::build_tree(rsc, rows);
|
||||
|
||||
if let Some(text) = in_progress {
|
||||
screen.composer.field.edit(rsc).set(&text);
|
||||
}
|
||||
if let Some(session_id) = self.selected.clone() {
|
||||
let field = screen.composer.field;
|
||||
rsc.register_event(field, Submit, move |ctx, rsc| {
|
||||
let text = field.edit(rsc).take();
|
||||
let text = text.trim().to_string();
|
||||
if !text.is_empty() {
|
||||
ctx.state.send_message(session_id.clone(), text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(self.transcript_ptr)(rsc).set(tree);
|
||||
self.screen = Some(screen);
|
||||
}
|
||||
}
|
||||
|
||||
fn session_row(
|
||||
rsc: &mut DefaultRsc<Client>,
|
||||
session: &SessionSummary,
|
||||
selected: bool,
|
||||
) -> StrongWidget {
|
||||
let bg = if selected {
|
||||
Srgba8::rgb(58, 90, 138)
|
||||
} else {
|
||||
Srgba8::rgb(38, 38, 44)
|
||||
};
|
||||
let id = session.id.clone();
|
||||
let label = format!("{}\n{}", session.title, session.status);
|
||||
wtext(label)
|
||||
.color(PaintId::WHITE)
|
||||
.wrap(true)
|
||||
.pad(10)
|
||||
.width(rest(1))
|
||||
.background(rect(bg))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
move |ctx, rsc: &mut DefaultRsc<Client>| {
|
||||
ctx.state.select_session(rsc, id.clone());
|
||||
},
|
||||
)
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
|
||||
wtext(message.to_string())
|
||||
.color(PaintId::WHITE)
|
||||
.wrap(true)
|
||||
.pad(16)
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use crate::client::config::EnrollmentStore;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
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-desktop")
|
||||
}
|
||||
|
||||
pub fn store() -> EnrollmentStore {
|
||||
EnrollmentStore::new(config_dir())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod startup;
|
||||
@@ -0,0 +1,70 @@
|
||||
//! The desktop binary's command line and the enrolment it resolves --
|
||||
//! `--link`/`--ca`, parsed once at startup and again from `app.rs`'s
|
||||
//! `Client::new`. Here rather than in `src/bin_desktop.rs` because both
|
||||
//! callers are in the library; the binary is only `fn main`.
|
||||
|
||||
use crate::client::config::EnrolledServer;
|
||||
|
||||
use super::config;
|
||||
|
||||
struct Args {
|
||||
ca_path: Option<std::path::PathBuf>,
|
||||
link: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut ca_path = None;
|
||||
let mut link = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--ca" => {
|
||||
ca_path = Some(std::path::PathBuf::from(
|
||||
args.next().ok_or("--ca needs a path")?,
|
||||
))
|
||||
}
|
||||
"--link" => link = Some(args.next().ok_or("--link needs a value")?),
|
||||
other => return Err(format!("unrecognised argument '{other}'")),
|
||||
}
|
||||
}
|
||||
Ok(Args { ca_path, link })
|
||||
}
|
||||
|
||||
pub fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
||||
let args = parse_args()?;
|
||||
let store = config::store();
|
||||
let server = match args.link {
|
||||
Some(link) => {
|
||||
let server = EnrolledServer::parse_link(&link)?;
|
||||
store
|
||||
.save(&server)
|
||||
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
|
||||
server
|
||||
}
|
||||
None => store
|
||||
.load()
|
||||
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"no server enrolled yet under {} -- pass --link 'aiapp://enroll?...' \
|
||||
once (app/ui-sandbox.sh's start banner prints one)",
|
||||
config::config_dir().display()
|
||||
)
|
||||
})?,
|
||||
};
|
||||
// `--ca` wins where it was given, so a caller can point a link's
|
||||
// server at a certificate it did not carry -- and so the flag still
|
||||
// means what it did before the link could carry one.
|
||||
let ca_pem = match (&args.ca_path, &server.ca_pem) {
|
||||
(Some(path), _) => std::fs::read(path)
|
||||
.map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?,
|
||||
(None, Some(pem)) => pem.clone().into_bytes(),
|
||||
(None, None) => {
|
||||
return Err("this enrollment carries no CA -- pass --ca PATH (e.g. \
|
||||
~/.config/ai-app/certs/ca.pem), or enrol again with a link \
|
||||
minted by a server that includes one"
|
||||
.to_string());
|
||||
}
|
||||
};
|
||||
Ok((server, ca_pem))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
pub mod client;
|
||||
|
||||
#[cfg(feature = "screens")]
|
||||
pub mod ui;
|
||||
|
||||
#[cfg(all(feature = "screens", not(target_os = "android")))]
|
||||
pub mod desktop;
|
||||
|
||||
#[cfg(all(feature = "screens", target_os = "android"))]
|
||||
pub mod android;
|
||||
|
||||
// `jni` 0.22's `native_method!` expands to `AtomicBool::fetch_update`,
|
||||
// which this toolchain deprecates in favour of `try_update`. The call is
|
||||
// inside the macro, so there is nothing here to migrate -- the fix is a
|
||||
// `jni` release, and this allow comes out when one lands. Scoped to the
|
||||
// module the macro is used in rather than the crate, so a deprecation in
|
||||
// our own code is still a warning.
|
||||
#[cfg(feature = "shell")]
|
||||
#[allow(deprecated)]
|
||||
pub mod shell;
|
||||
@@ -0,0 +1,99 @@
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
|
||||
use jni::refs::{Global, LoaderContext};
|
||||
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
|
||||
use jni::strings::JNIString;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
|
||||
|
||||
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
if CLASS_LOADER.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
|
||||
let loader_obj = call_method(
|
||||
env,
|
||||
&class_obj,
|
||||
"getClassLoader",
|
||||
"()Ljava/lang/ClassLoader;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
|
||||
let global = env.new_global_ref(&loader)?;
|
||||
// Lost the race with another entry point calling this concurrently --
|
||||
// both loaders name the same app, so either one is fine and there is
|
||||
// nothing to reconcile.
|
||||
let _ = CLASS_LOADER.set(global);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
|
||||
/// through the cached app classloader when one has been remembered, and
|
||||
/// through the ordinary default otherwise -- which is every call made
|
||||
/// before any entry point has run, and is also correct for a main-thread
|
||||
/// caller, so there is no case this makes worse.
|
||||
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||
match CLASS_LOADER.get() {
|
||||
Some(loader) => {
|
||||
let binary_name = name.replace('/', ".");
|
||||
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
|
||||
}
|
||||
None => env.find_class(JNIString::new(name)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||
resolve_class(env, name)
|
||||
}
|
||||
|
||||
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
|
||||
Ok(env.new_string(text)?.into())
|
||||
}
|
||||
|
||||
pub fn new_object<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JObject<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.new_object(class, sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn call_method<'local>(
|
||||
env: &mut Env<'local>,
|
||||
obj: &JObject,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn call_static_method<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn get_static_field<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
field: &str,
|
||||
sig: &str,
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeFieldSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.get_static_field(class, JNIString::new(field), sig.field_signature())
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
mod jcall;
|
||||
mod notify;
|
||||
mod settings;
|
||||
mod share;
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JClass, JObject};
|
||||
use jni::sys::jint;
|
||||
use jni::{Env, NativeMethod, native_method};
|
||||
|
||||
fn ensure_logger() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
#[cfg(target_os = "android")]
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Debug)
|
||||
.with_tag("android-shell"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// The parameters are spelled as their Java types, not as `JObject`: the
|
||||
// macro encodes each argument into the exported symbol's JNI signature
|
||||
// (and JNI resolves `Java_...` names *by* that signature), so a generic
|
||||
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
|
||||
// method actually declared `(Landroid/app/Activity;...)` -- two different
|
||||
// symbols that never resolve to each other, silently, with no compiler
|
||||
// error on either side. `android.app.Activity` etc. have no dedicated
|
||||
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
|
||||
// implementation functions below (the "Built-in Types" note in
|
||||
// `native_method!`'s docs).
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.MainActivity",
|
||||
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
fn native_handle_intent<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
activity: JObject<'local>,
|
||||
intent: JObject<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &activity)?;
|
||||
share::handle_intent(env, &activity, &intent)
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_sync(context: android.content.Context) -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
fn native_sync<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
context: JObject<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &context)?;
|
||||
notify::sync(env, &context)
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_on_start_command(service: android.app.Service) -> jint,
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
fn native_on_start_command<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
service: JObject<'local>,
|
||||
) -> Result<jint, jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &service)?;
|
||||
Ok(notify::on_start_command(env, service))
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_on_destroy() -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
fn native_on_destroy<'local>(
|
||||
_env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
notify::on_destroy();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
//! Where a notification is said, and the foreground service that keeps
|
||||
//! the connection open while the app is closed. Ported from
|
||||
//! `Notifications.kt`'s `NotificationService`, minus the "session on
|
||||
//! screen" / "hand to the app as a banner" branches: those read
|
||||
//! process-wide state that only exists because a screen is drawn to
|
||||
//! register against, and this experiment draws no screen yet (that is
|
||||
//! E4's job, on iris). So every notification here takes the third branch
|
||||
//! Kotlin's `show` already had -- the platform's own drawer -- which is
|
||||
//! also exactly the case E3's pass condition asks for: **a notification
|
||||
//! arrives with the app closed.**
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::client::api::UreqTransport;
|
||||
use crate::client::notifications::{SessionNotification, follow_notifications};
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JValue};
|
||||
use jni::sys::{JNI_TRUE, jint};
|
||||
|
||||
use crate::shell::settings::{self, ServerSettings};
|
||||
|
||||
const ALERT_CHANNEL: &str = "sessions";
|
||||
const ONGOING_CHANNEL: &str = "connection";
|
||||
const ONGOING_ID: i32 = 1;
|
||||
const ALERT_ID: i32 = 2;
|
||||
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
||||
|
||||
/// Whether the follow-loop thread is already running. **A deviation from
|
||||
/// `Notifications.kt`, found by testing rather than planned**: the Kotlin
|
||||
/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }`
|
||||
/// on *every* call, with nothing to notice a previous one is still going --
|
||||
/// and `sync()` calling `startForegroundService` when the service is
|
||||
/// already running is an ordinary Android start, not a restart, so
|
||||
/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls
|
||||
/// `sync` once itself, then again inside `handle_enrollment` after saving
|
||||
/// the token) hits exactly this path and was observed opening **two**
|
||||
/// concurrent connections to `/notifications` from one process -- caught
|
||||
/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached
|
||||
/// thread ai-app-notifications` lines for one enrollment. Guarded here
|
||||
/// rather than left to match Kotlin's behaviour exactly, since duplicating
|
||||
/// a live connection is a resource leak with no upside; worth carrying the
|
||||
/// same guard back to `Notifications.kt` separately.
|
||||
static RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
static STOPPING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
||||
crate::shell::jcall::get_static_field(env, class, field, "I")?.i()
|
||||
}
|
||||
|
||||
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"from",
|
||||
"(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;",
|
||||
&[JValue::Object(context)],
|
||||
)?
|
||||
.l()
|
||||
}
|
||||
|
||||
fn create_channel(
|
||||
env: &mut Env,
|
||||
manager: &JObject,
|
||||
id: &str,
|
||||
name: &str,
|
||||
importance: i32,
|
||||
) -> Result<()> {
|
||||
let id_j = crate::shell::jcall::jstr_obj(env, id)?;
|
||||
let builder = crate::shell::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationChannelCompat$Builder",
|
||||
"(Ljava/lang/String;I)V",
|
||||
&[JValue::Object(&id_j), JValue::Int(importance)],
|
||||
)?;
|
||||
let name_j = crate::shell::jcall::jstr_obj(env, name)?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"setName",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
||||
&[JValue::Object(&name_j)],
|
||||
)?;
|
||||
let channel = crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"build",
|
||||
"()Landroidx/core/app/NotificationChannelCompat;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
manager,
|
||||
"createNotificationChannel",
|
||||
"(Landroidx/core/app/NotificationChannelCompat;)V",
|
||||
&[JValue::Object(&channel)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two channels, because they are two different things to be told -- see
|
||||
/// `Notifications.kt`'s `createChannels` for the reasoning; the names and
|
||||
/// importances here are copied from it exactly, since a phone that has
|
||||
/// seen both apps should not learn two different vocabularies for the
|
||||
/// same fact.
|
||||
fn create_channels(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
let manager = notification_manager(env, context)?;
|
||||
let default = static_int(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"IMPORTANCE_DEFAULT",
|
||||
)?;
|
||||
let min = static_int(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"IMPORTANCE_MIN",
|
||||
)?;
|
||||
create_channel(
|
||||
env,
|
||||
&manager,
|
||||
ALERT_CHANNEL,
|
||||
"Sessions needing attention",
|
||||
default,
|
||||
)?;
|
||||
create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn new_intent_for<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
class_name: &str,
|
||||
) -> Result<JObject<'l>> {
|
||||
let target_class = crate::shell::jcall::find_class(env, class_name)?;
|
||||
crate::shell::jcall::new_object(
|
||||
env,
|
||||
"android/content/Intent",
|
||||
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
||||
&[JValue::Object(context), JValue::Object(&target_class)],
|
||||
)
|
||||
}
|
||||
|
||||
/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s
|
||||
/// `sessionIntent`, including building the URI through `Uri.Builder`
|
||||
/// rather than string concatenation, for the same reason: an id needing
|
||||
/// escaping must survive the round trip.
|
||||
fn session_intent<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
session_id: &str,
|
||||
) -> Result<JObject<'l>> {
|
||||
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
||||
let action_view = crate::shell::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&intent,
|
||||
"setAction",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
&[JValue::Object(&action_view)],
|
||||
)?;
|
||||
let builder = crate::shell::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
||||
let scheme = crate::shell::jcall::jstr_obj(env, settings::SCHEME)?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"scheme",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&scheme)],
|
||||
)?;
|
||||
let authority = crate::shell::jcall::jstr_obj(env, "session")?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"authority",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&authority)],
|
||||
)?;
|
||||
let path = crate::shell::jcall::jstr_obj(env, session_id)?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"appendPath",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&path)],
|
||||
)?;
|
||||
let uri = crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?
|
||||
.l()?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&intent,
|
||||
"setData",
|
||||
"(Landroid/net/Uri;)Landroid/content/Intent;",
|
||||
&[JValue::Object(&uri)],
|
||||
)?;
|
||||
Ok(intent)
|
||||
}
|
||||
|
||||
fn pending_activity<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
intent: &JObject,
|
||||
) -> Result<JObject<'l>> {
|
||||
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
||||
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"android/app/PendingIntent",
|
||||
"getActivity",
|
||||
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
|
||||
&[
|
||||
JValue::Object(context),
|
||||
JValue::Int(0),
|
||||
JValue::Object(intent),
|
||||
JValue::Int(update_current | immutable),
|
||||
],
|
||||
)?
|
||||
.l()
|
||||
}
|
||||
|
||||
fn builder_call<'l>(
|
||||
env: &mut Env<'l>,
|
||||
builder: &JObject<'l>,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<()> {
|
||||
crate::shell::jcall::call_method(env, builder, method, sig, args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The type Android 14+ requires a foreground service to declare, and
|
||||
/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`.
|
||||
fn foreground_type(env: &mut Env) -> Result<i32> {
|
||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||
let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?;
|
||||
if sdk >= upside_down_cake {
|
||||
static_int(
|
||||
env,
|
||||
"android/content/pm/ServiceInfo",
|
||||
"FOREGROUND_SERVICE_TYPE_SPECIAL_USE",
|
||||
)
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||
let channel = crate::shell::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
||||
let builder = crate::shell::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationCompat$Builder",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&channel)],
|
||||
)?;
|
||||
let title = crate::shell::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentTitle",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&title)],
|
||||
)?;
|
||||
let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setSmallIcon",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(icon)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setOngoing",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setPriority",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(priority_min)],
|
||||
)?;
|
||||
crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
||||
.l()
|
||||
}
|
||||
|
||||
/// Starts the service if there is a server to connect to, and stops it
|
||||
/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`.
|
||||
pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
let service_intent =
|
||||
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
||||
if settings::load(env, context)?.is_none() {
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
context,
|
||||
"stopService",
|
||||
"(Landroid/content/Intent;)Z",
|
||||
&[JValue::Object(&service_intent)],
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
create_channels(env, context)?;
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/content/ContextCompat",
|
||||
"startForegroundService",
|
||||
"(Landroid/content/Context;Landroid/content/Intent;)V",
|
||||
&[JValue::Object(context), JValue::Object(&service_intent)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
|
||||
match try_start(env, &service) {
|
||||
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
||||
Ok(false) => {
|
||||
let _ = crate::shell::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||
}
|
||||
Err(e) => {
|
||||
log_error(env, "onStartCommand", &e);
|
||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
||||
let Some(settings) = settings::load(env, service)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let ca = settings::load_pinned_ca(env)?;
|
||||
let notification = ongoing_notification(env, service)?;
|
||||
let fg_type = foreground_type(env)?;
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/app/ServiceCompat",
|
||||
"startForeground",
|
||||
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
|
||||
&[
|
||||
JValue::Object(service),
|
||||
JValue::Int(ONGOING_ID),
|
||||
JValue::Object(¬ification),
|
||||
JValue::Int(fg_type),
|
||||
],
|
||||
)?;
|
||||
|
||||
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
|
||||
// the first is still going -- the ordinary case for this service,
|
||||
// since `sync()` is called from more than one place -- must not open
|
||||
// a second connection.
|
||||
if RUNNING.swap(true, Ordering::SeqCst) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let vm = env.get_java_vm()?;
|
||||
let context = env.new_global_ref(service)?;
|
||||
STOPPING.store(false, Ordering::SeqCst);
|
||||
std::thread::Builder::new()
|
||||
.name("ai-app-notifications".to_string())
|
||||
.spawn(move || {
|
||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||
follow_loop(env, &context, settings, &ca);
|
||||
Ok(())
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
||||
while !STOPPING.load(Ordering::SeqCst) {
|
||||
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
||||
let _ = follow_notifications(&transport, |notification| {
|
||||
if let Err(e) = show(env, context, ¬ification) {
|
||||
log_error(env, "show", &e);
|
||||
}
|
||||
!STOPPING.load(Ordering::SeqCst)
|
||||
});
|
||||
}
|
||||
if STOPPING.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(RECONNECT_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
|
||||
let manager = notification_manager(env, context)?;
|
||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
|
||||
let allowed = if sdk < tiramisu {
|
||||
true
|
||||
} else {
|
||||
let permission =
|
||||
crate::shell::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
||||
let granted = static_int(
|
||||
env,
|
||||
"android/content/pm/PackageManager",
|
||||
"PERMISSION_GRANTED",
|
||||
)?;
|
||||
let result = crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/content/ContextCompat",
|
||||
"checkSelfPermission",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)I",
|
||||
&[JValue::Object(context), JValue::Object(&permission)],
|
||||
)?
|
||||
.i()?;
|
||||
result == granted
|
||||
};
|
||||
let enabled =
|
||||
crate::shell::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?
|
||||
.z()?;
|
||||
if !allowed || !enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let intent = session_intent(env, context, ¬ification.session_id)?;
|
||||
let pending = pending_activity(env, context, &intent)?;
|
||||
let channel = crate::shell::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
||||
let builder = crate::shell::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationCompat$Builder",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&channel)],
|
||||
)?;
|
||||
let title = crate::shell::jcall::jstr_obj(env, ¬ification.title)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentTitle",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&title)],
|
||||
)?;
|
||||
let text = crate::shell::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentText",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&text)],
|
||||
)?;
|
||||
let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setSmallIcon",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(icon)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentIntent",
|
||||
"(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&pending)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setAutoCancel",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let when = (notification.at * 1000.0) as i64;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setWhen",
|
||||
"(J)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Long(when)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setShowWhen",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let built = crate::shell::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"build",
|
||||
"()Landroid/app/Notification;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
let tag = crate::shell::jcall::jstr_obj(env, ¬ification.session_id)?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&manager,
|
||||
"notify",
|
||||
"(Ljava/lang/String;ILandroid/app/Notification;)V",
|
||||
&[
|
||||
JValue::Object(&tag),
|
||||
JValue::Int(ALERT_ID),
|
||||
JValue::Object(&built),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn on_destroy() {
|
||||
STOPPING.store(true, Ordering::SeqCst);
|
||||
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
||||
// comment): the old thread may still be inside a blocked read when a
|
||||
// new `onStartCommand` follows immediately, which would spawn a
|
||||
// second one before the first has actually stopped. Narrower than not
|
||||
// resetting at all -- a service destroyed and never restarted would
|
||||
// otherwise wedge `RUNNING` true forever -- and no worse than the
|
||||
// known gap already accepted above.
|
||||
RUNNING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
||||
let message = format!("android-shell: {where_}: {error}");
|
||||
let _ = (|| -> Result<()> {
|
||||
let tag = crate::shell::jcall::jstr_obj(env, "android-shell")?;
|
||||
let msg = crate::shell::jcall::jstr_obj(env, &message)?;
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"android/util/Log",
|
||||
"e",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)I",
|
||||
&[JValue::Object(&tag), JValue::Object(&msg)],
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Enrollment: where the backend is, and the Keystore-sealed token to
|
||||
//! reach it. This crate does not reimplement the Android Keystore AES-GCM
|
||||
//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin
|
||||
//! class the production app already uses (see `ServerConfig.kt`), through
|
||||
//! JNI, for two reasons: that code is shared with Dev Updater and already
|
||||
//! tested, and the sealed value on a real phone is keyed to the exact
|
||||
//! Keystore alias that class already uses -- reimplementing the crypto
|
||||
//! here would either duplicate it or invalidate an existing enrollment.
|
||||
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
|
||||
/// Where the backend is and how to authenticate to it -- the Rust twin of
|
||||
/// `wg-app-link`'s `ServerSettings` data class, read back field by field
|
||||
/// rather than kept as a live JNI reference, so it can cross a thread
|
||||
/// boundary (a `JObject` is tied to one `Env`/thread).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerSettings {
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl ServerSettings {
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("https://{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const SCHEME: &str = "aiappshell";
|
||||
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
||||
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
||||
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
||||
|
||||
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
||||
let scheme = crate::shell::jcall::jstr_obj(env, SCHEME)?;
|
||||
let alias = crate::shell::jcall::jstr_obj(env, KEY_ALIAS)?;
|
||||
crate::shell::jcall::new_object(
|
||||
env,
|
||||
STORE_CLASS,
|
||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||
&[JValue::Object(&scheme), JValue::Object(&alias)],
|
||||
)
|
||||
}
|
||||
|
||||
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
||||
let host = get_string(env, settings_obj, "getHost")?;
|
||||
let port = crate::shell::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
||||
let token = get_string(env, settings_obj, "getToken")?;
|
||||
Ok(ServerSettings { host, port, token })
|
||||
}
|
||||
|
||||
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
||||
let value =
|
||||
crate::shell::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
jstr.try_to_string(env)
|
||||
}
|
||||
|
||||
/// The stored enrollment, or `None` when there is not one -- mirrors
|
||||
/// `ServerConfig.kt`'s `loadServerSettings`.
|
||||
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
||||
let store = new_store(env)?;
|
||||
let settings_obj = crate::shell::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"load",
|
||||
"(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;",
|
||||
&[JValue::Object(context)],
|
||||
)?
|
||||
.l()?;
|
||||
if settings_obj.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(read_settings(env, &settings_obj)?))
|
||||
}
|
||||
|
||||
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
||||
let store = new_store(env)?;
|
||||
let host = crate::shell::jcall::jstr_obj(env, &settings.host)?;
|
||||
let token = crate::shell::jcall::jstr_obj(env, &settings.token)?;
|
||||
let settings_obj = crate::shell::jcall::new_object(
|
||||
env,
|
||||
SETTINGS_CLASS,
|
||||
"(Ljava/lang/String;ILjava/lang/String;)V",
|
||||
&[
|
||||
JValue::Object(&host),
|
||||
JValue::Int(settings.port),
|
||||
JValue::Object(&token),
|
||||
],
|
||||
)?;
|
||||
crate::shell::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"save",
|
||||
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
|
||||
&[JValue::Object(context), JValue::Object(&settings_obj)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
||||
let store = new_store(env)?;
|
||||
let settings_obj = crate::shell::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"parseEnrollmentUri",
|
||||
"(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;",
|
||||
&[JValue::Object(uri)],
|
||||
)?
|
||||
.l()?;
|
||||
if settings_obj.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(read_settings(env, &settings_obj)?))
|
||||
}
|
||||
|
||||
/// The CA this build pins, generated at build time the same way
|
||||
/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`)
|
||||
/// but into a plain Java constant, since this module has no Kotlin of its
|
||||
/// own to generate into.
|
||||
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
||||
let value = crate::shell::jcall::get_static_field(
|
||||
env,
|
||||
"com/example/aiapp/shell/PinnedCa",
|
||||
"PINNED_CA_PEM",
|
||||
"Ljava/lang/String;",
|
||||
)?
|
||||
.l()?;
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
Ok(jstr.try_to_string(env)?.into_bytes())
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use crate::client::api::{ApiClient, UreqTransport};
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
|
||||
use crate::shell::notify;
|
||||
use crate::shell::settings;
|
||||
|
||||
const ACTION_SEND: &str = "android.intent.action.SEND";
|
||||
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
||||
const ACTION_VIEW: &str = "android.intent.action.VIEW";
|
||||
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
||||
|
||||
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
|
||||
let value =
|
||||
crate::shell::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
Ok(Some(jstr.try_to_string(env)?))
|
||||
}
|
||||
|
||||
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
||||
let message = crate::shell::jcall::jstr_obj(env, message)?;
|
||||
crate::shell::jcall::call_static_method(
|
||||
env,
|
||||
"com/example/aiapp/shell/MainActivity",
|
||||
"toast",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&message)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||
let action = get_string_method(env, intent, "getAction")?;
|
||||
if matches!(
|
||||
action.as_deref(),
|
||||
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
|
||||
) {
|
||||
return handle_share(env, activity, intent);
|
||||
}
|
||||
if action.as_deref() != Some(ACTION_VIEW) {
|
||||
return Ok(());
|
||||
}
|
||||
let uri = crate::shell::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?
|
||||
.l()?;
|
||||
if uri.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let scheme = get_string_method(env, &uri, "getScheme")?;
|
||||
if scheme.as_deref() != Some(settings::SCHEME) {
|
||||
return Ok(());
|
||||
}
|
||||
match get_string_method(env, &uri, "getHost")?.as_deref() {
|
||||
Some("session") => handle_session_open(env, activity, &uri),
|
||||
Some("enroll") => handle_enrollment(env, activity, &uri),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
||||
return Ok(());
|
||||
};
|
||||
toast(env, activity, &format!("Opened session {session_id}"))
|
||||
}
|
||||
|
||||
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||
match settings::parse_enrollment_uri(env, uri)? {
|
||||
Some(parsed) => {
|
||||
settings::save(env, activity, &parsed)?;
|
||||
notify::sync(env, activity)?;
|
||||
toast(
|
||||
env,
|
||||
activity,
|
||||
&format!("Enrolled with {}", parsed.base_url()),
|
||||
)
|
||||
}
|
||||
None => toast(env, activity, "Not a valid enrollment code"),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||
let extra_text = crate::shell::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||
let text = crate::shell::jcall::call_method(
|
||||
env,
|
||||
intent,
|
||||
"getStringExtra",
|
||||
"(Ljava/lang/String;)Ljava/lang/String;",
|
||||
&[JValue::Object(&extra_text)],
|
||||
)?
|
||||
.l()?;
|
||||
let text = if text.is_null() {
|
||||
None
|
||||
} else {
|
||||
let jstr: JString = env.cast_local::<JString>(text)?;
|
||||
Some(jstr.try_to_string(env)?)
|
||||
};
|
||||
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
|
||||
return toast(
|
||||
env,
|
||||
activity,
|
||||
"Nothing to share -- only shared text is supported so far",
|
||||
);
|
||||
};
|
||||
|
||||
// Network I/O must not run on the calling thread: `handle_intent` is
|
||||
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
|
||||
// blocking socket read there is a `NetworkOnMainThreadException`. So
|
||||
// the actual send happens on a JNI-attached background thread, the
|
||||
// same shape `notify::try_start`'s follow loop uses; `toast` from that
|
||||
// thread is safe because `MainActivity.toast` itself hops back to the
|
||||
// main looper (see that method).
|
||||
let vm = env.get_java_vm()?;
|
||||
let activity_ref = env.new_global_ref(activity)?;
|
||||
std::thread::spawn(move || {
|
||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||
share_in_background(env, &activity_ref, text);
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
|
||||
let outcome = attach_to_a_session(env, activity, &text);
|
||||
let message = match outcome {
|
||||
Ok(title) => format!("Shared into \"{title}\""),
|
||||
Err(message) => message,
|
||||
};
|
||||
let _ = toast(env, activity, &message);
|
||||
}
|
||||
|
||||
fn attach_to_a_session(
|
||||
env: &mut Env,
|
||||
activity: &JObject,
|
||||
text: &str,
|
||||
) -> std::result::Result<String, String> {
|
||||
let settings = settings::load(env, activity)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "Not enrolled yet".to_string())?;
|
||||
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
|
||||
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let client = ApiClient::new(transport);
|
||||
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
|
||||
let target = sessions
|
||||
.into_iter()
|
||||
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
|
||||
.ok_or_else(|| "No session to share into".to_string())?;
|
||||
client
|
||||
.send_message(&target.id, text, &[])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(target.title)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use crate::ui::theme::Theme;
|
||||
use iris::prelude::*;
|
||||
|
||||
const MAX_LINES: f32 = 6.0;
|
||||
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
|
||||
const FIELD_PAD_DP: f32 = 12.0;
|
||||
|
||||
/// `field` is exposed so the caller can read its content on submit
|
||||
/// (`field.edit(rsc).text()`) and clear it afterward
|
||||
/// (`field.edit(rsc).set("")`).
|
||||
pub struct Composer {
|
||||
pub field: WeakWidget<TextEdit>,
|
||||
/// The bar's own outer padding -- only `bottom` is ever changed, by
|
||||
/// [`Self::set_bottom_inset`]. A `Pad` around the whole bar rather than
|
||||
/// a rebuilt tree, because `field` lives inside it and cannot be
|
||||
/// re-added to a new wrapper once it is strongly owned here.
|
||||
outer_pad: WeakWidget<Pad>,
|
||||
}
|
||||
|
||||
impl Composer {
|
||||
/// Called by the platform shell (Android's `on_insets_changed`, e.g.)
|
||||
/// whenever the space below the bar changes: the IME's own inset while
|
||||
/// it is open, the navigation-bar inset otherwise. Takes a plain
|
||||
/// `f32` in the caller's own physical-pixel units rather than an
|
||||
/// Android-specific insets type, so this crate stays usable from the
|
||||
/// winit backend too, which has no navigation bar to report.
|
||||
/// Rewrites the existing `Pad` in place (marking it dirty through the
|
||||
/// ordinary `Widgets::get_mut` path) instead of swapping in a new one,
|
||||
/// so the field's focus, selection and in-progress text are untouched.
|
||||
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
|
||||
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
|
||||
pad.padding.bottom = Len::abs(inset);
|
||||
pad.exact_region = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the composer plus its own bar as a **weak** id -- the caller
|
||||
/// (`lib.rs::build`) embeds it in the screen's own top-level tuple, whose
|
||||
/// `set_root` performs the one real strong registration. Calling
|
||||
/// `.add_strong`/`.upgrade` a second time on an id already strong-owned
|
||||
/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same
|
||||
/// mistake this box's `row.rs` first made with its sender-label header, see
|
||||
/// that file's comment for the fuller account.
|
||||
pub fn build_composer<Rsc: HasEvents>(rsc: &mut Rsc, theme: &Theme) -> (Composer, WeakWidget)
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let field = wtext("")
|
||||
.editable(EditMode::MultiLine)
|
||||
.text_align(Align::LEFT)
|
||||
.wrap(true)
|
||||
.size(18)
|
||||
.color(theme.text.clone())
|
||||
.attr::<Selectable>(())
|
||||
.label("Message")
|
||||
.add(rsc);
|
||||
|
||||
// Without any mask at all the overflow paints *above* the bar, over
|
||||
// the transcript: measured at 58px of stray text for a 475px message
|
||||
// in a 417px box.
|
||||
let content = field
|
||||
.width(rest(1))
|
||||
.scrollable(Axis::Y, Pin::End)
|
||||
.pad(dp(FIELD_PAD_DP))
|
||||
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
||||
.width(rest(1))
|
||||
.masked_by(rect(theme.composer_surface.clone()))
|
||||
.add(rsc);
|
||||
|
||||
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
|
||||
|
||||
(Composer { field, outer_pad }, outer_pad)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use crate::client::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
|
||||
use event_model::SeqEvent;
|
||||
use iris::prelude::*;
|
||||
|
||||
pub const BACKLOG_COUNT: usize = 3202;
|
||||
|
||||
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
||||
|
||||
pub const PHONE_WIDTH: f32 = 1080.0;
|
||||
pub const PHONE_HEIGHT: f32 = 2424.0;
|
||||
pub const PHONE_SCALE: f32 = 2.55;
|
||||
pub const PHONE_FRAME_MS: u64 = 8;
|
||||
|
||||
pub fn phone_size() -> Vec2 {
|
||||
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
|
||||
}
|
||||
|
||||
pub struct Fixture {
|
||||
pub backlog: Vec<serde_json::Value>,
|
||||
pub stream_tail: Vec<SeqEvent>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
/// Parses the whole fixture. Panics on malformed input: this is a
|
||||
/// generated file compiled into the binary, so a parse failure is a
|
||||
/// broken build rather than a condition a caller could recover from
|
||||
/// (CODE_RULES: separate recoverable conditions from programmer
|
||||
/// error).
|
||||
pub fn parse() -> Self {
|
||||
let mut backlog = Vec::with_capacity(BACKLOG_COUNT);
|
||||
let mut stream_tail = Vec::new();
|
||||
for (i, line) in FIXTURE_JSONL
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.enumerate()
|
||||
{
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
|
||||
if i < BACKLOG_COUNT {
|
||||
backlog.push(value);
|
||||
} else {
|
||||
stream_tail.push(
|
||||
serde_json::from_value(value)
|
||||
.expect("bench fixture event matches event-model's SeqEvent"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
backlog,
|
||||
stream_tail,
|
||||
}
|
||||
}
|
||||
|
||||
/// The opening page folded into transcript items -- the same
|
||||
/// `fold_page` a real first load runs. `Err` carries the fold's own
|
||||
/// message, which a caller shows on screen rather than panicking, so
|
||||
/// a fixture that stops folding is visible in the app instead of
|
||||
/// being a crash on launch.
|
||||
pub fn backlog_items(&self) -> Result<Vec<TranscriptItem>, String> {
|
||||
fold_page(&self.backlog)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||
group_tool_runs(items)
|
||||
}
|
||||
|
||||
/// Everything a caller needs to run the fixture as an app screen would:
|
||||
/// the screen, the folded items behind it, and the events not yet
|
||||
/// streamed. The tree itself comes back separately from
|
||||
/// [`build_screen`], since whoever takes it owns it.
|
||||
pub struct Opened {
|
||||
pub screen: crate::ui::TranscriptScreen,
|
||||
pub items: Vec<TranscriptItem>,
|
||||
/// The tail, for a caller that goes on replaying it one event at a
|
||||
/// time through `fold_event`/`TranscriptScreen::apply` -- the
|
||||
/// streaming phase of either app's benchmark.
|
||||
pub stream_tail: Vec<SeqEvent>,
|
||||
}
|
||||
|
||||
/// Build the transcript screen over the fixture's opening page, without
|
||||
/// claiming the window's root -- `crate::ui::build_tree`'s own split,
|
||||
/// for a caller (the Android bench) that puts the screen inside a shell
|
||||
/// of its own.
|
||||
pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String>
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let fixture = Fixture::parse();
|
||||
let items = fixture.backlog_items()?;
|
||||
let (screen, tree) = crate::ui::build_tree(rsc, rows(&items));
|
||||
Ok((
|
||||
Opened {
|
||||
screen,
|
||||
items,
|
||||
stream_tail: fixture.stream_tail,
|
||||
},
|
||||
tree,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn open<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
ui_state: &mut impl HasRoot<Rsc>,
|
||||
) -> Result<Opened, String>
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let (opened, tree) = build_screen(rsc)?;
|
||||
ui_state.set_root(rsc, tree);
|
||||
Ok(opened)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_fixture_has_a_backlog_and_a_streaming_tail() {
|
||||
let fixture = Fixture::parse();
|
||||
assert_eq!(fixture.backlog.len(), BACKLOG_COUNT);
|
||||
assert!(
|
||||
fixture.stream_tail.len() >= 400,
|
||||
"the stream phase replays 400 events; the fixture has {}",
|
||||
fixture.stream_tail.len()
|
||||
);
|
||||
assert!(!fixture.backlog_items().expect("the page folds").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
use crate::client::highlight::{self, Kind, Language};
|
||||
use crate::client::markdown_blocks::{Block, BlockKind};
|
||||
use crate::ui::theme::Theme;
|
||||
use iris::prelude::*;
|
||||
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use std::ops::Range;
|
||||
|
||||
fn syntax_color(kind: Kind, theme: &Theme) -> PaintId {
|
||||
match kind {
|
||||
Kind::Keyword => theme.syntax_keyword.clone(),
|
||||
Kind::String => theme.syntax_string.clone(),
|
||||
Kind::Literal => theme.syntax_literal.clone(),
|
||||
Kind::Comment => theme.syntax_comment.clone(),
|
||||
Kind::Metadata => theme.syntax_metadata.clone(),
|
||||
Kind::Punctuation => theme.syntax_punctuation.clone(),
|
||||
Kind::Mark => theme.syntax_mark.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BlockFrame {
|
||||
Plain,
|
||||
Verbatim { fill: PaintId },
|
||||
Quote,
|
||||
}
|
||||
|
||||
pub fn frame_of(kind: BlockKind, theme: &Theme) -> BlockFrame {
|
||||
match kind {
|
||||
BlockKind::Code => BlockFrame::Verbatim {
|
||||
fill: theme.verbatim_surface.clone(),
|
||||
},
|
||||
BlockKind::Table => BlockFrame::Verbatim {
|
||||
fill: theme.table_surface.clone(),
|
||||
},
|
||||
BlockKind::Quote => BlockFrame::Quote,
|
||||
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
|
||||
BlockFrame::Plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Link {
|
||||
pub range: Range<usize>,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Rendered {
|
||||
pub text: String,
|
||||
pub spans: Vec<SpanStyle>,
|
||||
pub links: Vec<Link>,
|
||||
}
|
||||
|
||||
impl Rendered {
|
||||
pub fn link_at(&self, byte: usize) -> Option<&Link> {
|
||||
self.links.iter().find(|l| l.range.contains(&byte))
|
||||
}
|
||||
}
|
||||
|
||||
/// The heading ladder, in points at a 16pt body: it starts near the body
|
||||
/// text and descends, because these are headings inside a chat message
|
||||
/// rather than the top of a document. The numbers are Material's
|
||||
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
|
||||
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
|
||||
/// -- kept as literals rather than derived from `base_size` so the two
|
||||
/// apps agree exactly.
|
||||
fn heading_size(level: HeadingLevel) -> f32 {
|
||||
match level {
|
||||
HeadingLevel::H1 => 24.0,
|
||||
HeadingLevel::H2 => 22.0,
|
||||
HeadingLevel::H3 => 16.0,
|
||||
HeadingLevel::H4 => 14.0,
|
||||
HeadingLevel::H5 => 12.0,
|
||||
HeadingLevel::H6 => 11.0,
|
||||
}
|
||||
}
|
||||
|
||||
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
|
||||
|
||||
/// A block-level separator inside one block's own text (a list item's
|
||||
/// paragraphs, a quote's): two never run into each other with no gap, but
|
||||
/// an empty `out` gets no leading blank.
|
||||
fn ensure_blank_line(out: &mut String) {
|
||||
if !out.is_empty() && !out.ends_with("\n\n") {
|
||||
while out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_line(out: &mut String) {
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_block(block: &Block, base_size: f32, theme: &Theme) -> Rendered {
|
||||
match block.kind {
|
||||
BlockKind::Table => table_text(&block.source, theme),
|
||||
_ => render_markdown(&block.source, base_size, theme),
|
||||
}
|
||||
}
|
||||
|
||||
/// One markdown source string rendered into plain text plus the spans that
|
||||
/// style it. `base_size` is the row's ordinary paragraph font size, needed
|
||||
/// only so a heading's override is relative to it rather than a hardcoded
|
||||
/// absolute the caller cannot retune.
|
||||
pub fn render_markdown(src: &str, base_size: f32, theme: &Theme) -> Rendered {
|
||||
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
|
||||
let mut out = String::new();
|
||||
let mut spans = Vec::new();
|
||||
let mut links = Vec::new();
|
||||
// Stack of start byte offsets for whatever inline/block styling is
|
||||
// currently open -- pulldown-cmark's `Start`/`End` events are always
|
||||
// balanced and each `End` already names its own kind (`TagEnd`), so a
|
||||
// plain offset stack (rather than a tree, or repeating the kind here
|
||||
// too) is enough. A link's destination rides along beside its offset,
|
||||
// since `TagEnd::Link` does not carry it.
|
||||
let mut open: Vec<(usize, Option<String>)> = Vec::new();
|
||||
// One entry per open list: `Some(next number)` for an ordered list,
|
||||
// `None` for a bulleted one. Depth is this vector's length, which is
|
||||
// what picks the bullet glyph.
|
||||
let mut lists: Vec<Option<u64>> = Vec::new();
|
||||
let mut fence_language: Option<Language> = None;
|
||||
|
||||
let parser = Parser::new_ext(src, options());
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(tag) => match tag {
|
||||
Tag::Heading { .. }
|
||||
| Tag::Emphasis
|
||||
| Tag::Strong
|
||||
| Tag::Strikethrough
|
||||
| Tag::Image { .. } => open.push((out.len(), None)),
|
||||
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
|
||||
Tag::CodeBlock(kind) => {
|
||||
fence_language = match &kind {
|
||||
CodeBlockKind::Fenced(info) => {
|
||||
highlight::fence_language(info.split_whitespace().next())
|
||||
}
|
||||
CodeBlockKind::Indented => None,
|
||||
};
|
||||
ensure_blank_line(&mut out);
|
||||
open.push((out.len(), None));
|
||||
}
|
||||
Tag::Item => {
|
||||
ensure_line(&mut out);
|
||||
let depth = lists.len().max(1);
|
||||
out.push_str(&" ".repeat(depth - 1));
|
||||
let start = out.len();
|
||||
match lists.last_mut() {
|
||||
Some(Some(n)) => {
|
||||
out.push_str(&format!("{n}. "));
|
||||
*n += 1;
|
||||
}
|
||||
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
|
||||
}
|
||||
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
|
||||
}
|
||||
Tag::List(first) => lists.push(first),
|
||||
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
|
||||
_ => {}
|
||||
},
|
||||
Event::End(
|
||||
tag_end @ (TagEnd::Heading(_)
|
||||
| TagEnd::Emphasis
|
||||
| TagEnd::Strong
|
||||
| TagEnd::Strikethrough
|
||||
| TagEnd::Link
|
||||
| TagEnd::Image
|
||||
| TagEnd::CodeBlock),
|
||||
) => {
|
||||
let Some((start, dest)) = open.pop() else {
|
||||
continue;
|
||||
};
|
||||
if matches!(tag_end, TagEnd::CodeBlock) {
|
||||
while out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
}
|
||||
let range = start..out.len();
|
||||
if range.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match tag_end {
|
||||
TagEnd::Heading(level) => {
|
||||
spans.push(SpanStyle::new(range).font_size(heading_size(level)).bold());
|
||||
}
|
||||
TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()),
|
||||
TagEnd::Strong => spans.push(SpanStyle::new(range).bold()),
|
||||
TagEnd::Strikethrough => {
|
||||
spans.push(SpanStyle::new(range).color(theme.strikethrough.clone()));
|
||||
}
|
||||
// An image draws as its alt text until the port has a
|
||||
// transcript image widget (IRIS_TODO's "scaled
|
||||
// thumbnail"); marked as a link so it is at least
|
||||
// followable rather than silently inert.
|
||||
TagEnd::Link | TagEnd::Image => {
|
||||
spans.push(
|
||||
SpanStyle::new(range.clone())
|
||||
.color(theme.link.clone())
|
||||
.underline(),
|
||||
);
|
||||
if let Some(url) = dest {
|
||||
links.push(Link { range, url });
|
||||
}
|
||||
}
|
||||
TagEnd::CodeBlock => {
|
||||
spans.push(
|
||||
SpanStyle::new(range.clone())
|
||||
.family(Family::Monospace)
|
||||
.color(theme.code.clone()),
|
||||
);
|
||||
if let Some(language) = fence_language.take() {
|
||||
highlight_into(&mut spans, &out, range, language, theme);
|
||||
}
|
||||
}
|
||||
_ => unreachable!("filtered by the outer match arm"),
|
||||
}
|
||||
}
|
||||
Event::Text(text) => out.push_str(&text),
|
||||
Event::Code(text) => {
|
||||
let start = out.len();
|
||||
out.push_str(&text);
|
||||
spans.push(
|
||||
SpanStyle::new(start..out.len())
|
||||
.family(Family::Monospace)
|
||||
.color(theme.code.clone()),
|
||||
);
|
||||
}
|
||||
Event::SoftBreak => out.push(' '),
|
||||
Event::HardBreak => out.push('\n'),
|
||||
Event::Rule => {
|
||||
ensure_line(&mut out);
|
||||
out.push_str("\u{2500}\u{2500}\u{2500}\n");
|
||||
}
|
||||
Event::TaskListMarker(done) => {
|
||||
let start = out.len();
|
||||
out.push_str(if done { "[x] " } else { "[ ] " });
|
||||
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
|
||||
}
|
||||
Event::End(TagEnd::List(_)) => {
|
||||
lists.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
while out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
spans.retain(|s| s.range.end <= out.len());
|
||||
links.retain(|l| l.range.end <= out.len());
|
||||
Rendered {
|
||||
text: out,
|
||||
spans,
|
||||
links,
|
||||
}
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||
}
|
||||
|
||||
pub(crate) fn highlight_into(
|
||||
spans: &mut Vec<SpanStyle>,
|
||||
text: &str,
|
||||
range: Range<usize>,
|
||||
language: Language,
|
||||
theme: &Theme,
|
||||
) {
|
||||
let code = &text[range.clone()];
|
||||
// char index -> byte offset within `code`, plus the end, so a span's
|
||||
// `end` is always in range.
|
||||
let bytes: Vec<usize> = code
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.chain(std::iter::once(code.len()))
|
||||
.collect();
|
||||
for span in highlight::spans_of(code, language) {
|
||||
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
|
||||
debug_assert!(
|
||||
false,
|
||||
"highlight span {}..{} outside {} chars of code",
|
||||
span.start,
|
||||
span.end,
|
||||
bytes.len() - 1
|
||||
);
|
||||
continue;
|
||||
};
|
||||
spans.push(
|
||||
SpanStyle::new(range.start + start..range.start + end)
|
||||
.family(Family::Monospace)
|
||||
.color(syntax_color(span.kind, theme)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const TABLE_MAX_COL: usize = 28;
|
||||
|
||||
pub fn table_text(src: &str, theme: &Theme) -> Rendered {
|
||||
let rows = table_cells(src);
|
||||
if rows.is_empty() {
|
||||
return Rendered::default();
|
||||
}
|
||||
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let wrapped: Vec<Vec<Vec<String>>> = rows
|
||||
.iter()
|
||||
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
|
||||
.collect();
|
||||
let widths: Vec<usize> = (0..columns)
|
||||
.map(|c| {
|
||||
wrapped
|
||||
.iter()
|
||||
.filter_map(|row| row.get(c))
|
||||
.flat_map(|lines| lines.iter())
|
||||
.map(|l| l.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut out = String::new();
|
||||
let mut spans = Vec::new();
|
||||
for (r, row) in wrapped.iter().enumerate() {
|
||||
let height = row.iter().map(Vec::len).max().unwrap_or(1);
|
||||
let start = out.len();
|
||||
for line in 0..height {
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
for (c, width) in widths.iter().enumerate() {
|
||||
if c > 0 {
|
||||
out.push_str(" ");
|
||||
}
|
||||
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
|
||||
let text = text.unwrap_or("");
|
||||
out.push_str(text);
|
||||
if c + 1 < widths.len() {
|
||||
for _ in text.chars().count()..*width {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if r == 0 {
|
||||
spans.push(SpanStyle::new(start..out.len()).bold());
|
||||
out.push('\n');
|
||||
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
|
||||
let rule_start = out.len();
|
||||
out.extend(std::iter::repeat_n('\u{2500}', rule));
|
||||
spans.push(SpanStyle::new(rule_start..out.len()).color(theme.quote_bar.clone()));
|
||||
}
|
||||
}
|
||||
Rendered {
|
||||
text: out,
|
||||
spans,
|
||||
links: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn table_cells(src: &str) -> Vec<Vec<String>> {
|
||||
let mut rows: Vec<Vec<String>> = Vec::new();
|
||||
let mut cell = String::new();
|
||||
let mut in_cell = false;
|
||||
for event in Parser::new_ext(src, options()) {
|
||||
match event {
|
||||
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
|
||||
Event::Start(Tag::TableCell) => {
|
||||
cell.clear();
|
||||
in_cell = true;
|
||||
}
|
||||
Event::End(TagEnd::TableCell) => {
|
||||
in_cell = false;
|
||||
if let Some(row) = rows.last_mut() {
|
||||
row.push(cell.trim().to_string());
|
||||
}
|
||||
}
|
||||
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
|
||||
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
rows.retain(|r| !r.is_empty());
|
||||
rows
|
||||
}
|
||||
|
||||
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut line = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
let extra = if line.is_empty() { 0 } else { 1 };
|
||||
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
|
||||
lines.push(std::mem::take(&mut line));
|
||||
}
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
}
|
||||
lines.push(line);
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client::markdown_blocks::split_blocks;
|
||||
|
||||
fn with_theme<T>(f: impl FnOnce(&Theme) -> T) -> T {
|
||||
let mut paints = Paints::new();
|
||||
let theme = Theme::new(&mut paints);
|
||||
f(&theme)
|
||||
}
|
||||
|
||||
fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||
with_theme(|theme| super::render_markdown(src, base_size, theme))
|
||||
}
|
||||
|
||||
fn render_block(block: &Block, base_size: f32) -> Rendered {
|
||||
with_theme(|theme| super::render_block(block, base_size, theme))
|
||||
}
|
||||
|
||||
fn frame_of(kind: BlockKind) -> BlockFrame {
|
||||
with_theme(|theme| super::frame_of(kind, theme))
|
||||
}
|
||||
|
||||
fn syntax_color(kind: Kind) -> PaintId {
|
||||
with_theme(|theme| super::syntax_color(kind, theme))
|
||||
}
|
||||
|
||||
fn code_color() -> PaintId {
|
||||
with_theme(|theme| theme.code.clone())
|
||||
}
|
||||
|
||||
fn marker_color() -> PaintId {
|
||||
with_theme(|theme| theme.marker.clone())
|
||||
}
|
||||
|
||||
fn block(src: &str) -> Rendered {
|
||||
let blocks = split_blocks(src);
|
||||
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
|
||||
render_block(&blocks[0], 16.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_paragraph_has_no_spans() {
|
||||
let r = render_markdown("just some words", 16.0);
|
||||
assert_eq!(r.text, "just some words");
|
||||
assert!(r.spans.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_and_italic_produce_spans_over_the_right_range() {
|
||||
let r = render_markdown("a **bold** and *italic* word", 16.0);
|
||||
assert_eq!(r.text, "a bold and italic word");
|
||||
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
|
||||
assert_eq!(&r.text[bold.range.clone()], "bold");
|
||||
let italic = r.spans.iter().find(|s| s.italic).unwrap();
|
||||
assert_eq!(&r.text[italic.range.clone()], "italic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_gets_a_bigger_font_size_span() {
|
||||
let r = render_markdown("# A Title", 16.0);
|
||||
assert!(r.text.starts_with("A Title"));
|
||||
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
|
||||
assert_eq!(&r.text[heading.range.clone()], "A Title");
|
||||
assert_eq!(heading.font_size, Some(24.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_heading_level_is_a_different_size() {
|
||||
let mut sizes = Vec::new();
|
||||
for level in 1..=6 {
|
||||
let src = format!("{} h", "#".repeat(level));
|
||||
let r = render_markdown(&src, 16.0);
|
||||
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
|
||||
}
|
||||
let mut sorted = sizes.clone();
|
||||
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
|
||||
sorted.dedup();
|
||||
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
|
||||
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
|
||||
assert!(r.text.contains("the docs"));
|
||||
assert!(
|
||||
!r.text.contains("example.com"),
|
||||
"the URL should not leak into the visible text"
|
||||
);
|
||||
let link = r.spans.iter().find(|s| s.underline).unwrap();
|
||||
assert_eq!(&r.text[link.range.clone()], "the docs");
|
||||
let at = r.text.find("docs").unwrap();
|
||||
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
|
||||
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
|
||||
let past = r.text.find("for").unwrap();
|
||||
assert!(r.link_at(past).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
|
||||
let r = block("```rust\nlet x = 1; // note\n```");
|
||||
assert_eq!(r.text, "let x = 1; // note");
|
||||
let keyword = r
|
||||
.spans
|
||||
.iter()
|
||||
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||
.expect("a rust fence colours its keywords");
|
||||
assert_eq!(&r.text[keyword.range.clone()], "let");
|
||||
let comment = r
|
||||
.spans
|
||||
.iter()
|
||||
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
|
||||
.unwrap();
|
||||
assert_eq!(&r.text[comment.range.clone()], "// note");
|
||||
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
|
||||
let r = block("```brainfuck\nlet x = 1;\n```");
|
||||
assert_eq!(r.text, "let x = 1;");
|
||||
assert_eq!(r.spans.len(), 1);
|
||||
assert!(r.spans[0].family == Some(Family::Monospace));
|
||||
assert_eq!(r.spans[0].color, Some(code_color()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
|
||||
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
|
||||
for span in &r.spans {
|
||||
assert!(
|
||||
r.text.is_char_boundary(span.range.start)
|
||||
&& r.text.is_char_boundary(span.range.end),
|
||||
"span {:?} is not on a char boundary of {:?}",
|
||||
span.range,
|
||||
r.text
|
||||
);
|
||||
}
|
||||
let string = r
|
||||
.spans
|
||||
.iter()
|
||||
.find(|s| s.color == Some(syntax_color(Kind::String)))
|
||||
.unwrap();
|
||||
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unterminated_fence_still_renders_what_arrived() {
|
||||
let r = block("```rust\nlet x = 1;");
|
||||
assert_eq!(r.text, "let x = 1;");
|
||||
assert!(
|
||||
r.spans
|
||||
.iter()
|
||||
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
|
||||
let r = block("- one\n- two\n - deep");
|
||||
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
|
||||
let markers: Vec<_> = r
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| s.color == Some(marker_color()))
|
||||
.map(|s| r.text[s.range.clone()].to_string())
|
||||
.collect();
|
||||
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
|
||||
let r = block("3. three\n4. four");
|
||||
assert_eq!(r.text, "3. three\n4. four");
|
||||
let markers: Vec<_> = r
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| s.color == Some(marker_color()))
|
||||
.map(|s| r.text[s.range.clone()].to_string())
|
||||
.collect();
|
||||
assert_eq!(markers, ["3. ", "4. "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quote_is_its_text_and_takes_the_quote_frame() {
|
||||
let blocks = split_blocks("> quoted words\n> still quoted");
|
||||
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
|
||||
let r = render_block(&blocks[0], 16.0);
|
||||
assert_eq!(r.text, "quoted words still quoted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
|
||||
use BlockKind::*;
|
||||
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(Heading), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(List), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(Other), BlockFrame::Plain);
|
||||
assert_eq!(frame_of(Quote), BlockFrame::Quote);
|
||||
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
|
||||
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
|
||||
assert_ne!(
|
||||
frame_of(Code),
|
||||
frame_of(Table),
|
||||
"a fence and a table sit on different fills"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_pads_its_columns_to_the_widest_cell() {
|
||||
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
|
||||
let lines: Vec<&str> = r.text.lines().collect();
|
||||
assert_eq!(lines[0], "a bb");
|
||||
assert_eq!(lines[1], "\u{2500}".repeat(8));
|
||||
assert_eq!(lines[2], "cccc d");
|
||||
let bold = r.spans.iter().find(|s| s.bold).unwrap();
|
||||
assert_eq!(&r.text[bold.range.clone()], "a bb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_table_cell_wraps_inside_its_column() {
|
||||
let long = "one two three four five six seven eight nine ten eleven twelve";
|
||||
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
|
||||
for line in r.text.lines() {
|
||||
assert!(
|
||||
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
|
||||
"line too wide: {line:?}"
|
||||
);
|
||||
}
|
||||
assert!(r.text.contains("twelve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_task_list_marks_its_boxes() {
|
||||
let r = block("- [x] done\n- [ ] not");
|
||||
assert!(r.text.contains("[x] done"));
|
||||
assert!(r.text.contains("[ ] not"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
pub mod composer;
|
||||
// The checked-in bench fixture opened as a real screen -- 1.9 MB of
|
||||
// `include_str!`, so it is a feature rather than always present: a build
|
||||
// meant for a phone must not carry it. `bench` turns it on; so does the
|
||||
// default, which is what makes `cargo test` here run the harness tests.
|
||||
#[cfg(feature = "fixture")]
|
||||
pub mod fixture;
|
||||
pub mod markdown;
|
||||
pub mod row;
|
||||
pub(crate) mod tap;
|
||||
pub mod theme;
|
||||
pub mod tool;
|
||||
|
||||
use crate::client::transcript_fold::TranscriptRow as FoldedRow;
|
||||
use iris::prelude::*;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
use theme::Theme;
|
||||
|
||||
pub struct TranscriptScreen {
|
||||
/// The transcript's own `LazySpan` -- the layout *and* the scroll
|
||||
/// position, since a lazy span owns a `ScrollController` of its own
|
||||
/// rather than being wrapped in a `ScrollArea` (`docs/SCROLL.md`).
|
||||
/// Exposed so a caller can read `.extent()`, drive it through
|
||||
/// `Scrollable` (`.scroll()`, `.fling()`, `.amt()`) or call
|
||||
/// `.jump_to_end()` directly.
|
||||
pub list: WeakWidget<LazySpan>,
|
||||
pub composer: composer::Composer,
|
||||
rebuilds: std::cell::Cell<usize>,
|
||||
tail: RefCell<Option<(RowKey, row::TailRow)>>,
|
||||
session_working: std::cell::Cell<bool>,
|
||||
theme: Rc<Theme>,
|
||||
}
|
||||
|
||||
impl TranscriptScreen {
|
||||
/// Append one more folded row at the live end of the transcript --
|
||||
/// what a caller's SSE loop or a sent message calls as new events
|
||||
/// arrive. `LazySpan::push_back` is O(1) and keeps the view pinned to the
|
||||
/// newest content when it already was (I3).
|
||||
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
// Capped like any other row (`row::build_row`'s `cap`). A reply
|
||||
// that goes on to *grow* past the cap is never capped, because it
|
||||
// grows through `RowBlocks::apply_delta`, which appends to what is
|
||||
// already drawn -- so the cap only ever catches a row that arrived
|
||||
// long, which is the one nobody is watching arrive.
|
||||
let (key, widget, tail) = row::build_row(
|
||||
rsc,
|
||||
self.list,
|
||||
row,
|
||||
self.session_working.get(),
|
||||
true,
|
||||
self.theme.clone(),
|
||||
);
|
||||
(self.list)(rsc).push_back(LazyItem::new(key, widget));
|
||||
*self.tail.borrow_mut() = tail.map(|t| (key, t));
|
||||
}
|
||||
|
||||
pub fn set_session_working<Rsc: HasEvents>(&self, rsc: &mut Rsc, working: bool)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
if self.session_working.replace(working) == working {
|
||||
return;
|
||||
}
|
||||
let mut tail = self.tail.borrow_mut();
|
||||
if let Some((_, row::TailRow::Tools(tools))) = tail.as_mut() {
|
||||
let calls = tools.calls();
|
||||
tools.apply_calls(rsc, &calls, working);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn tail_card_count(&self) -> usize {
|
||||
match self.tail.borrow().as_ref() {
|
||||
Some((_, row::TailRow::Tools(tools))) => tools.card_count(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open or close the newest row's tool run, when it is one -- what a
|
||||
/// caller with no finger needs (`run-headless.sh`'s screenshot on this
|
||||
/// displayless machine, and the tests below). Answers whether there
|
||||
/// was such a row to act on, so a caller that expected one can say so
|
||||
/// rather than silently producing the collapsed picture.
|
||||
pub fn expand_tail_tools<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let tail = self.tail.borrow();
|
||||
let Some((_, row::TailRow::Tools(tools))) = tail.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
tools.set_group_expanded(rsc, expanded);
|
||||
true
|
||||
}
|
||||
|
||||
/// The `ReplaceLast` fast path: update the tail row in place if this
|
||||
/// really is a change to the same row, and say whether that worked.
|
||||
/// `false` for anything the caller must rebuild instead.
|
||||
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let mut tail = self.tail.borrow_mut();
|
||||
let Some((tail_key, kept)) = tail.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if *tail_key != key {
|
||||
return false;
|
||||
}
|
||||
match (kept, row) {
|
||||
(row::TailRow::Blocks(blocks), FoldedRow::Single(item)) => {
|
||||
let (sender, markdown_src) = row::item_content(item);
|
||||
// A tool call is drawn as a card, never as markdown, so a
|
||||
// row that kept blocks and now holds one is a different
|
||||
// row -- rebuild it.
|
||||
if matches!(
|
||||
item,
|
||||
crate::client::transcript_fold::TranscriptItem::ToolRun { .. }
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
blocks.apply_delta(rsc, sender, &markdown_src)
|
||||
}
|
||||
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
|
||||
tools.apply_calls(rsc, calls, self.session_working.get())
|
||||
}
|
||||
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
|
||||
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working.get())
|
||||
}
|
||||
(row::TailRow::Blocks(_), FoldedRow::Tools(_)) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything else -- a row *before* the tail changed, which only
|
||||
/// happens when `group_tool_runs` regroups already-seen items (a tool
|
||||
/// run's calls that used to be separate rows join once the run closes)
|
||||
/// -- falls back to a full rebuild: every row is dropped
|
||||
/// (`LazySpan::clear`) and rebuilt from `new`. Counted in
|
||||
/// [`Self::take_rebuilds`] so a caller (a report, a test) can see how
|
||||
/// often the fallback actually fires rather than assuming it never
|
||||
/// does.
|
||||
pub fn apply<Rsc: HasEvents>(
|
||||
&self,
|
||||
rsc: &mut Rsc,
|
||||
old: &[crate::client::transcript_fold::TranscriptItem],
|
||||
new: &[crate::client::transcript_fold::TranscriptItem],
|
||||
) where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
use crate::client::transcript_fold::group_tool_runs;
|
||||
|
||||
let old_rows = group_tool_runs(old);
|
||||
let new_rows = group_tool_runs(new);
|
||||
|
||||
match diff_rows(&old_rows, &new_rows) {
|
||||
RowDiff::Unchanged => {}
|
||||
RowDiff::Appended { common } => {
|
||||
for row in &new_rows[common..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
RowDiff::ReplaceLast { common } => {
|
||||
let old_key = row::row_key(&old_rows[common].key());
|
||||
let new_key = row::row_key(&new_rows[common].key());
|
||||
if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) {
|
||||
for row in &new_rows[common + 1..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let (new_key, widget, kept) = row::build_row(
|
||||
rsc,
|
||||
self.list,
|
||||
&new_rows[common],
|
||||
self.session_working.get(),
|
||||
false,
|
||||
self.theme.clone(),
|
||||
);
|
||||
let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget));
|
||||
drop(evicted); // frees the old row's widget, same as a pop would
|
||||
*self.tail.borrow_mut() = kept.map(|t| (new_key, t));
|
||||
for row in &new_rows[common + 1..] {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
RowDiff::Rebuild => {
|
||||
self.rebuilds.set(self.rebuilds.get() + 1);
|
||||
(self.list)(rsc).clear();
|
||||
*self.tail.borrow_mut() = None;
|
||||
for row in &new_rows {
|
||||
self.push_row(rsc, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_rebuilds(&self) -> usize {
|
||||
self.rebuilds.replace(0)
|
||||
}
|
||||
|
||||
/// The semantic paint IDs used by this screen. A caller can replace
|
||||
/// their entries through `rsc.ui_mut().paints.set(...)`; retained text
|
||||
/// and rect primitives keep the IDs and need no widget rebuild.
|
||||
pub fn theme(&self) -> &Theme {
|
||||
&self.theme
|
||||
}
|
||||
|
||||
/// The concatenated text of whatever is currently selected across one
|
||||
/// or more rows, `None` if nothing is -- what a copy command reads.
|
||||
pub fn selected_text<Rsc: HasEvents>(&self, rsc: &mut Rsc) -> Option<String> {
|
||||
let id = rsc
|
||||
.events()
|
||||
.controllers
|
||||
.id::<SelectionController>(self.list.id())?;
|
||||
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
|
||||
selection.selected_text(rsc)
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
ui_state: &mut impl HasRoot<Rsc>,
|
||||
rows: Vec<FoldedRow>,
|
||||
) -> TranscriptScreen
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let (screen, tree) = build_tree(rsc, rows);
|
||||
ui_state.set_root(rsc, tree);
|
||||
screen
|
||||
}
|
||||
|
||||
pub fn build_tree<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
rows: Vec<FoldedRow>,
|
||||
) -> (TranscriptScreen, StrongWidget)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let theme = Rc::new(Theme::new(&mut rsc.ui_mut().paints));
|
||||
let list = LazySpan::new(Dir::DOWN, Pin::End).add(rsc);
|
||||
list.controller(
|
||||
SelectionController::new()
|
||||
.with_scroll(list)
|
||||
.separator("\n\n"),
|
||||
)
|
||||
.add(rsc);
|
||||
|
||||
// The last row's block widgets are kept for the same reason
|
||||
// `push_row` keeps them: a reply that is *already* streaming when the
|
||||
// screen is built takes its next delta through `apply`, and a `None`
|
||||
// here would send that delta down the rebuild path instead -- the
|
||||
// whole message re-shaped, which is exactly what the per-block column
|
||||
// exists to avoid, and nothing on screen or in `take_rebuilds` would
|
||||
// say so.
|
||||
let mut tail = None;
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
// `false`: a row built here is history until the caller says the
|
||||
// session is working (`TranscriptScreen::set_session_working`),
|
||||
// and claiming a call is running because the screen happens to be
|
||||
// opening is exactly the inferred-as-measured mistake.
|
||||
// `cap`: every row but the last. The last is the tail, which may
|
||||
// be a reply already streaming when this screen opened, and a
|
||||
// capped row cannot take a delta (`RowBlocks::capped`).
|
||||
let cap = i + 1 < rows.len();
|
||||
let (key, widget, kept) = row::build_row(rsc, list, row, false, cap, theme.clone());
|
||||
list(rsc).push_back(LazyItem::new(key, widget));
|
||||
tail = kept.map(|t| (key, t));
|
||||
}
|
||||
|
||||
// The controller host covers gaps as well as text, so a tap anywhere in
|
||||
// the transcript can dismiss a selection. Text and link listeners may
|
||||
// see the same physical sample; `SelectionController` deduplicates it by
|
||||
// the sample's own timestamp while still returning the same tap decision
|
||||
// to whichever leaf owns the link action.
|
||||
{
|
||||
list.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||
let input = &ctx.data;
|
||||
rsc.with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
|
||||
selection.drag(id, rsc, input)
|
||||
});
|
||||
})
|
||||
.add(rsc);
|
||||
}
|
||||
|
||||
list.on(CursorSense::Scroll(Axis::Y), |ctx, rsc| {
|
||||
let delta = ctx.data.scroll_delta.y * 50.0;
|
||||
ctx.widget(rsc).scroll(delta);
|
||||
})
|
||||
.add(rsc);
|
||||
|
||||
let (composer, composer_bar) = composer::build_composer(rsc, &theme);
|
||||
|
||||
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
|
||||
(
|
||||
TranscriptScreen {
|
||||
tail: RefCell::new(tail),
|
||||
session_working: std::cell::Cell::new(false),
|
||||
list,
|
||||
composer,
|
||||
rebuilds: std::cell::Cell::new(0),
|
||||
theme,
|
||||
},
|
||||
tree,
|
||||
)
|
||||
}
|
||||
|
||||
/// What changed at the tail between two folded row lists -- the decision
|
||||
/// [`TranscriptScreen::apply`] acts on. Kept as its own pure function, no
|
||||
/// widget and no `Rsc`, so the three cases can be tested directly against
|
||||
/// synthetic `Vec<FoldedRow>`s (below) rather than needing a full widget
|
||||
/// harness to exercise logic that never touches one.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum RowDiff {
|
||||
Unchanged,
|
||||
Appended { common: usize },
|
||||
ReplaceLast { common: usize },
|
||||
Rebuild,
|
||||
}
|
||||
|
||||
fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff {
|
||||
let common = old
|
||||
.iter()
|
||||
.zip(new.iter())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
|
||||
if common == old.len() && common == new.len() {
|
||||
RowDiff::Unchanged
|
||||
} else if common == old.len() {
|
||||
RowDiff::Appended { common }
|
||||
} else if !old.is_empty() && common == old.len() - 1 && common < new.len() {
|
||||
RowDiff::ReplaceLast { common }
|
||||
} else {
|
||||
RowDiff::Rebuild
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod diff_tests {
|
||||
use super::*;
|
||||
use crate::client::transcript_fold::TranscriptItem;
|
||||
|
||||
fn user(seq: u64, text: &str) -> FoldedRow {
|
||||
FoldedRow::Single(TranscriptItem::UserMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn assistant(seq: u64, text: &str, settled: bool) -> FoldedRow {
|
||||
FoldedRow::Single(TranscriptItem::AssistantMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
settled,
|
||||
})
|
||||
}
|
||||
|
||||
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
|
||||
TranscriptItem::ToolRun {
|
||||
seq,
|
||||
id: format!("id{seq}"),
|
||||
run_id: run_id.to_string(),
|
||||
tool: "grep".to_string(),
|
||||
input: "x".to_string(),
|
||||
output: String::new(),
|
||||
done: false,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_lists_are_unchanged() {
|
||||
let rows = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||
assert_eq!(diff_rows(&rows, &rows.clone()), RowDiff::Unchanged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_list_growing_by_one_is_an_append_from_zero() {
|
||||
let old: Vec<FoldedRow> = Vec::new();
|
||||
let new = vec![user(1, "hi")];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_message_after_a_settled_reply_is_a_pure_append() {
|
||||
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 2 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delta_into_the_open_reply_is_a_last_row_replace() {
|
||||
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||
let new = vec![user(1, "hi"), assistant(2, "hello", false)];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delta_that_both_settles_the_reply_and_starts_the_next_row_is_still_a_replace() {
|
||||
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback() {
|
||||
let old = vec![FoldedRow::Single(tool(1, "run-a")), user(2, "meanwhile")];
|
||||
let new = vec![FoldedRow::Tools(vec![tool(1, "run-a"), tool(3, "run-a")])];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_the_list_is_a_rebuild() {
|
||||
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||
let new = vec![user(1, "hi")];
|
||||
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod apply_tests {
|
||||
use super::*;
|
||||
use crate::client::transcript_fold::TranscriptItem;
|
||||
|
||||
struct TestFocus {
|
||||
focus: Option<WeakWidget<TextEdit>>,
|
||||
}
|
||||
impl OpenUrl for TestFocus {
|
||||
fn open_url(&mut self, _url: &str) {}
|
||||
}
|
||||
|
||||
impl FocusHost for TestFocus {
|
||||
fn recent_click(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||
self.focus = id;
|
||||
}
|
||||
fn focus_gained(&mut self, _region: Option<PixelRegion>) {}
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||
self.focus == Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
struct TestRsc {
|
||||
ui: UiData,
|
||||
events: EventManager<TestRsc>,
|
||||
}
|
||||
impl UiRsc for TestRsc {
|
||||
fn ui(&self) -> &UiData {
|
||||
&self.ui
|
||||
}
|
||||
fn ui_mut(&mut self) -> &mut UiData {
|
||||
&mut self.ui
|
||||
}
|
||||
fn on_draw(&mut self, active: &ActiveData) {
|
||||
self.events.draw(active);
|
||||
}
|
||||
fn on_undraw(&mut self, active: &ActiveData) {
|
||||
self.events.undraw(active);
|
||||
}
|
||||
fn on_remove(&mut self, id: WidgetId) {
|
||||
self.events.remove(id);
|
||||
}
|
||||
}
|
||||
impl HasState for TestRsc {
|
||||
type State = TestFocus;
|
||||
}
|
||||
impl HasEvents for TestRsc {
|
||||
fn events(&self) -> &EventManager<Self> {
|
||||
&self.events
|
||||
}
|
||||
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||
&mut self.events
|
||||
}
|
||||
}
|
||||
|
||||
fn user(seq: u64, text: &str) -> TranscriptItem {
|
||||
TranscriptItem::UserMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant(seq: u64, text: &str) -> TranscriptItem {
|
||||
TranscriptItem::AssistantMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
settled: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(paragraphs: usize, tail: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for i in 0..paragraphs {
|
||||
out.push_str(&format!("Paragraph number {i} of a streamed reply.\n\n"));
|
||||
}
|
||||
out.push_str(tail);
|
||||
out
|
||||
}
|
||||
|
||||
fn cost_of_one_delta(paragraphs: usize) -> (u64, u64) {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let old_items = vec![assistant(1, &reply(paragraphs, "and the last one is st"))];
|
||||
let new_items = vec![assistant(
|
||||
1,
|
||||
&reply(paragraphs, "and the last one is still going."),
|
||||
)];
|
||||
let (screen, tree) = build_tree(
|
||||
&mut rsc,
|
||||
crate::client::transcript_fold::group_tool_runs(&old_items),
|
||||
);
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, &mut rsc);
|
||||
render.take_counters();
|
||||
|
||||
screen.apply(&mut rsc, &old_items, &new_items);
|
||||
render.update(&tree, &mut rsc);
|
||||
assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken");
|
||||
let (draws, _, _, shapes) = render.take_counters();
|
||||
(draws, shapes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one() {
|
||||
assert!(
|
||||
reply(100, "").len() > 3_000,
|
||||
"the long case must actually be a long message"
|
||||
);
|
||||
let (short_draws, short_shapes) = cost_of_one_delta(1);
|
||||
let (long_draws, long_shapes) = cost_of_one_delta(100);
|
||||
assert_eq!(
|
||||
short_draws, long_draws,
|
||||
"a delta into a 100-paragraph reply redrew {long_draws} widgets against \
|
||||
{short_draws} for a one-paragraph reply -- the earlier blocks are not being kept"
|
||||
);
|
||||
assert_eq!(
|
||||
(short_shapes, long_shapes),
|
||||
(1, 1),
|
||||
"a delta shaped {long_shapes} text layouts in a 100-paragraph reply and \
|
||||
{short_shapes} in a one-paragraph one; it must be the last block and nothing else"
|
||||
);
|
||||
}
|
||||
|
||||
fn call(id: &str, output: &str, done: bool) -> TranscriptItem {
|
||||
TranscriptItem::ToolRun {
|
||||
seq: 1,
|
||||
id: id.to_string(),
|
||||
run_id: "run".to_string(),
|
||||
tool: "Bash".to_string(),
|
||||
input: format!(r#"{{"command":"grep -rn {id} ."}}"#),
|
||||
output: output.to_string(),
|
||||
done,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_of(count: usize, output: &str, done: bool) -> Vec<TranscriptItem> {
|
||||
(0..count)
|
||||
.map(|i| call(&format!("t{i}"), output, done))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn open_run(
|
||||
rsc: &mut TestRsc,
|
||||
items: &[TranscriptItem],
|
||||
) -> (TranscriptScreen, StrongWidget, UiRenderState) {
|
||||
let (screen, tree) =
|
||||
build_tree(rsc, crate::client::transcript_fold::group_tool_runs(items));
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, rsc);
|
||||
assert!(
|
||||
screen.expand_tail_tools(rsc, true),
|
||||
"the fixture's only row must be the tool run"
|
||||
);
|
||||
render.update(&tree, rsc);
|
||||
render.take_counters();
|
||||
(screen, tree, render)
|
||||
}
|
||||
|
||||
fn shapes_to_open(output: &str) -> u64 {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let items = run_of(3, output, true);
|
||||
let (screen, tree) = build_tree(
|
||||
&mut rsc,
|
||||
crate::client::transcript_fold::group_tool_runs(&items),
|
||||
);
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, &mut rsc);
|
||||
render.take_counters();
|
||||
|
||||
assert!(
|
||||
screen.expand_tail_tools(&mut rsc, true),
|
||||
"the fixture's only row must be the tool run"
|
||||
);
|
||||
render.update(&tree, &mut rsc);
|
||||
let (_, _, _, shapes) = render.take_counters();
|
||||
shapes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_cards_shape_only_their_summary_lines() {
|
||||
let long: String = std::iter::repeat_n("a line of tool output\n", 4_000).collect();
|
||||
assert!(long.len() > 80_000, "the long case must actually be long");
|
||||
|
||||
let short_shapes = shapes_to_open("ok\n");
|
||||
let long_shapes = shapes_to_open(&long);
|
||||
assert!(
|
||||
short_shapes > 0,
|
||||
"opening a group must shape something, or this compares two zeroes"
|
||||
);
|
||||
assert_eq!(
|
||||
short_shapes, long_shapes,
|
||||
"three collapsed cards shaped {long_shapes} text layouts over 80 kB of output \
|
||||
against {short_shapes} over three bytes -- a collapsed card is laying out \
|
||||
something it does not draw"
|
||||
);
|
||||
}
|
||||
|
||||
fn shapes_for_message(text: &str) -> u64 {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let items = vec![
|
||||
TranscriptItem::AssistantMsg {
|
||||
seq: 1,
|
||||
text: text.to_string(),
|
||||
settled: true,
|
||||
},
|
||||
TranscriptItem::AssistantMsg {
|
||||
seq: 2,
|
||||
text: "ok".to_string(),
|
||||
settled: true,
|
||||
},
|
||||
];
|
||||
let (_screen, tree) = build_tree(
|
||||
&mut rsc,
|
||||
crate::client::transcript_fold::group_tool_runs(&items),
|
||||
);
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, &mut rsc);
|
||||
let (_, _, _, shapes) = render.take_counters();
|
||||
shapes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_message_is_drawn_only_as_far_as_the_cap() {
|
||||
let paragraphs = |n: usize| "a paragraph of a reply\n\n".repeat(n);
|
||||
let capped = shapes_for_message(¶graphs(crate::client::text_cap::MESSAGE_LINES * 4));
|
||||
let bigger = shapes_for_message(¶graphs(crate::client::text_cap::MESSAGE_LINES * 40));
|
||||
assert!(
|
||||
capped > 0,
|
||||
"the screen shaped nothing, so this compares zeroes"
|
||||
);
|
||||
assert_eq!(
|
||||
capped, bigger,
|
||||
"a message ten times longer cost {bigger} text layouts against {capped} -- the cap \
|
||||
is not bounding what gets laid out",
|
||||
);
|
||||
}
|
||||
|
||||
fn cost_of_one_result(count: usize) -> u64 {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let before = run_of(count, "", false);
|
||||
let mut after = before.clone();
|
||||
after[0] = call("t0", "the result", true);
|
||||
|
||||
let (screen, tree, mut render) = open_run(&mut rsc, &before);
|
||||
screen.apply(&mut rsc, &before, &after);
|
||||
render.update(&tree, &mut rsc);
|
||||
assert_eq!(
|
||||
screen.take_rebuilds(),
|
||||
0,
|
||||
"a result arriving must not rebuild the whole screen"
|
||||
);
|
||||
let (draws, _, _, _) = render.take_counters();
|
||||
draws
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_arriving_redraws_one_card_whatever_the_run_holds() {
|
||||
let small = cost_of_one_result(3);
|
||||
let large = cost_of_one_result(12);
|
||||
assert!(
|
||||
small > 0,
|
||||
"a result must redraw *something*, or this compares two zeroes"
|
||||
);
|
||||
assert_eq!(
|
||||
small, large,
|
||||
"one result redrew {large} widgets in a twelve-call run against {small} in a \
|
||||
three-call one -- the other cards are being rebuilt with it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_opens_and_closes_and_keeps_its_state_across_a_result() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let before = run_of(3, "", false);
|
||||
let mut after = before.clone();
|
||||
after[1] = call("t1", "done", true);
|
||||
|
||||
let (screen, tree) = build_tree(
|
||||
&mut rsc,
|
||||
crate::client::transcript_fold::group_tool_runs(&before),
|
||||
);
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((1080.0, 20000.0));
|
||||
render.update(&tree, &mut rsc);
|
||||
|
||||
assert_eq!(screen.tail_card_count(), 0);
|
||||
assert!(screen.expand_tail_tools(&mut rsc, true));
|
||||
assert_eq!(screen.tail_card_count(), 3);
|
||||
|
||||
screen.apply(&mut rsc, &before, &after);
|
||||
render.update(&tree, &mut rsc);
|
||||
assert_eq!(screen.take_rebuilds(), 0);
|
||||
assert_eq!(
|
||||
screen.tail_card_count(),
|
||||
3,
|
||||
"the group closed under a result"
|
||||
);
|
||||
|
||||
assert!(screen.expand_tail_tools(&mut rsc, false));
|
||||
assert_eq!(screen.tail_card_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_joining_an_open_run_appends_one_card() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let before = run_of(2, "ok", true);
|
||||
let mut after = before.clone();
|
||||
after.push(call("t2", "", false));
|
||||
|
||||
let (screen, tree, mut render) = open_run(&mut rsc, &before);
|
||||
assert_eq!(screen.tail_card_count(), 2);
|
||||
screen.apply(&mut rsc, &before, &after);
|
||||
render.update(&tree, &mut rsc);
|
||||
assert_eq!(
|
||||
screen.take_rebuilds(),
|
||||
0,
|
||||
"an appended call is not a rebuild"
|
||||
);
|
||||
assert_eq!(screen.tail_card_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tail_that_stops_being_tool_calls_falls_back_to_a_rebuild() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let before = vec![user(1, "stable"), call("t0", "", false)];
|
||||
let after = vec![user(1, "stable"), user(2, "not a tool call at all")];
|
||||
let (screen, _tree) = build_tree(
|
||||
&mut rsc,
|
||||
crate::client::transcript_fold::group_tool_runs(&before),
|
||||
);
|
||||
screen.apply(&mut rsc, &before, &after);
|
||||
assert_eq!(
|
||||
screen.take_rebuilds(),
|
||||
0,
|
||||
"this is a ReplaceLast, not a whole-screen rebuild"
|
||||
);
|
||||
assert_eq!(screen.tail_card_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
use crate::client::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
||||
use crate::client::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label};
|
||||
use crate::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||
use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block};
|
||||
use crate::ui::tap::{hold_edge, on_tap};
|
||||
use crate::ui::theme::Theme;
|
||||
use crate::ui::tool::ToolRow;
|
||||
use iris::prelude::*;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
const BLOCK_GAP_DP: f32 = 8.0;
|
||||
|
||||
pub const BASE_SIZE: f32 = 16.0;
|
||||
|
||||
/// `ItemKey::Seq` already is the `RowKey` (`u64`) this crate's `LazySpan` wants.
|
||||
/// `ItemKey::RunId` is a string (a tool call's own id), so it is hashed into
|
||||
/// one -- collisions are not a correctness risk worth guarding against here
|
||||
/// (a `DefaultHasher` collision across the run ids one session produces is
|
||||
/// astronomically unlikely, and the consequence of one would only be two
|
||||
/// tool-call rows sharing a list slot, not data loss), and the high bit is
|
||||
/// forced on so a hashed key can never collide with a real sequence number
|
||||
/// (this build never produces 2^63 events).
|
||||
pub fn row_key(key: &crate::client::transcript_fold::ItemKey) -> RowKey {
|
||||
use crate::client::transcript_fold::ItemKey;
|
||||
use std::hash::{Hash, Hasher};
|
||||
match key {
|
||||
ItemKey::Seq(seq) => *seq,
|
||||
ItemKey::RunId(id) => {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
id.hash(&mut h);
|
||||
h.finish() | (1 << 63)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sender label shown above a row's text, and the markdown source to
|
||||
/// render below it. `None` for a system-style note that has no sender.
|
||||
pub(crate) fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
|
||||
match item {
|
||||
TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()),
|
||||
TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()),
|
||||
TranscriptItem::ErrorMsg { message, .. } => (Some("Error"), message.clone()),
|
||||
TranscriptItem::CommandRow { text, .. } => (Some("Command"), format!("`/{text}`")),
|
||||
TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()),
|
||||
TranscriptItem::Note { text, .. } => (None, text.clone()),
|
||||
TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()),
|
||||
// Epoch seconds as-is until the port has a relative-time formatter
|
||||
// (P1); the Compose `LimitRow` draws it as a countdown.
|
||||
TranscriptItem::LimitNote { resets_at, .. } => (
|
||||
None,
|
||||
match resets_at {
|
||||
Some(at) => format!("_Usage limit reached; resets at {at:.0} (epoch seconds)._"),
|
||||
None => "_Usage limit reached._".to_string(),
|
||||
},
|
||||
),
|
||||
TranscriptItem::CompactedNote {
|
||||
pre_tokens,
|
||||
post_tokens,
|
||||
..
|
||||
} => (
|
||||
None,
|
||||
match (pre_tokens, post_tokens) {
|
||||
(Some(pre), Some(post)) => format!("_Compacted: {pre} -> {post} tokens._"),
|
||||
_ => "_Compacted._".to_string(),
|
||||
},
|
||||
),
|
||||
TranscriptItem::ImageItem { r#ref, .. } => (None, format!("_[image: {ref}]_")),
|
||||
TranscriptItem::QuestionCard(card) => (Some("Question"), question_markdown(card)),
|
||||
TranscriptItem::ToolRun {
|
||||
tool,
|
||||
input,
|
||||
output,
|
||||
..
|
||||
} => (Some(tool.as_str()), tool_call_markdown(tool, input, output)),
|
||||
}
|
||||
}
|
||||
|
||||
fn question_markdown(card: &QuestionCard) -> String {
|
||||
let mut out = card.prompt.clone();
|
||||
for opt in &card.options {
|
||||
out.push_str(&format!("\n- {}", opt.label));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
|
||||
let mut out = format!("**{tool}**\n\n```\n{input}\n```");
|
||||
if !output.is_empty() {
|
||||
out.push_str(&format!("\n\n```\n{output}\n```"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct RowBlocks {
|
||||
blocks: Vec<Block>,
|
||||
fields: Vec<WeakWidget<Text>>,
|
||||
links: Vec<Rc<RefCell<Vec<Link>>>>,
|
||||
column: WeakWidget<Span>,
|
||||
sender: Option<String>,
|
||||
/// Whether this row draws less than the whole message
|
||||
/// ([`cap_message`]). A delta cannot be appended to a capped row --
|
||||
/// the new text would go on *below* the "Show all" that says it is
|
||||
/// hidden -- so [`RowBlocks::apply_delta`] refuses one and the caller
|
||||
/// rebuilds instead.
|
||||
///
|
||||
/// Never `true` for the row a reply is actually streaming into: the
|
||||
/// live tail is built uncapped ([`build_row`]'s `cap`), which is what
|
||||
/// keeps the refusal from costing anything in practice. This field is
|
||||
/// the belt to that braces.
|
||||
capped: bool,
|
||||
theme: Rc<Theme>,
|
||||
}
|
||||
|
||||
/// Split for display: never empty, so a row with nothing in it yet is
|
||||
/// still one (empty) text widget rather than no widget at all -- an empty
|
||||
/// column reports a zero size and the row would vanish from the list.
|
||||
fn display_blocks(markdown_src: &str) -> Vec<Block> {
|
||||
let blocks = split_blocks(markdown_src);
|
||||
if blocks.is_empty() {
|
||||
vec![Block {
|
||||
kind: BlockKind::Paragraph,
|
||||
source: markdown_src.to_string(),
|
||||
}]
|
||||
} else {
|
||||
blocks
|
||||
}
|
||||
}
|
||||
|
||||
/// `blocks` cut to what a row draws, with the line count of the **whole**
|
||||
/// message; `None` when all of it fits.
|
||||
///
|
||||
/// The cut prefers a **block boundary**, because a message is markdown and
|
||||
/// a whole paragraph is a smaller version of a message in a way that half
|
||||
/// a paragraph is not. Where one block is over the bound by itself -- the
|
||||
/// reply that is one enormous fence -- that block is truncated instead of
|
||||
/// being dropped or drawn whole: dropping it would leave a row saying
|
||||
/// nothing, and a truncated fence still renders as a fence, since the
|
||||
/// renderer already knows the block's kind and pulldown-cmark closes an
|
||||
/// unterminated one at the end of its input.
|
||||
fn cap_message(blocks: Vec<Block>, cap: bool) -> (Vec<Block>, Option<usize>) {
|
||||
let total = || blocks.iter().map(|b| b.source.lines().count()).sum();
|
||||
if !cap {
|
||||
return (blocks, None);
|
||||
}
|
||||
let mut kept = Vec::with_capacity(blocks.len());
|
||||
let (mut lines_left, mut bytes_left) = (MESSAGE_LINES, MESSAGE_BYTES);
|
||||
for block in &blocks {
|
||||
if lines_left == 0 || bytes_left == 0 {
|
||||
return (kept, Some(total()));
|
||||
}
|
||||
match cut(&block.source, lines_left, bytes_left) {
|
||||
Some((head, _)) if kept.is_empty() => {
|
||||
kept.push(Block {
|
||||
kind: block.kind,
|
||||
source: head.to_string(),
|
||||
});
|
||||
return (kept, Some(total()));
|
||||
}
|
||||
Some(_) => return (kept, Some(total())),
|
||||
None => {
|
||||
lines_left -= block.source.lines().count().min(lines_left);
|
||||
bytes_left -= block.source.len().min(bytes_left);
|
||||
kept.push(block.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(kept, None)
|
||||
}
|
||||
|
||||
/// A message's own text, kept so that asking for the whole of a capped row
|
||||
/// can rebuild it. `Rc` rather than a copy per closure: the source of a
|
||||
/// long message is the largest string in the row, and the tap handler
|
||||
/// would otherwise hold a second one for the lifetime of the row.
|
||||
struct RowSource {
|
||||
sender: Option<String>,
|
||||
markdown: String,
|
||||
}
|
||||
|
||||
const FRAME_PAD_DP: f32 = 10.0;
|
||||
const QUOTE_BAR_DP: f32 = 3.0;
|
||||
const FRAME_RADIUS_DP: f32 = 8.0;
|
||||
|
||||
fn build_block<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
block: &Block,
|
||||
theme: &Theme,
|
||||
) -> (WeakWidget<Text>, StrongWidget, Rc<RefCell<Vec<Link>>>)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let frame = frame_of(block.kind, theme);
|
||||
let rendered = render_block(block, BASE_SIZE, theme);
|
||||
let links = Rc::new(RefCell::new(rendered.links));
|
||||
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
|
||||
let field = wtext(rendered.text)
|
||||
.spans(rendered.spans)
|
||||
.text_align(Align::LEFT)
|
||||
.wrap(!verbatim)
|
||||
.family(if verbatim {
|
||||
Family::Monospace
|
||||
} else {
|
||||
Family::SansSerif
|
||||
})
|
||||
.size(BASE_SIZE)
|
||||
.color(match frame {
|
||||
BlockFrame::Quote => theme.quote_text.clone(),
|
||||
_ => theme.text.clone(),
|
||||
})
|
||||
.add(rsc);
|
||||
|
||||
let tap_links = links.clone();
|
||||
field
|
||||
.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||
let (pos, size) = (ctx.data.pos, ctx.data.size);
|
||||
let input = &ctx.data;
|
||||
let outcome = rsc
|
||||
.with_nearest_controller::<SelectionController, _>(field, |id, selection, rsc| {
|
||||
selection.drag(id, rsc, input)
|
||||
})
|
||||
.unwrap_or(SelectionInput::Tapped);
|
||||
// A *tap*, decided by the same `DragArbiter` the pan and
|
||||
// the selection are: a gesture that panned the list past
|
||||
// this link, or held long enough to select, must not also
|
||||
// follow it (`GestureOutcome::Tapped`'s doc).
|
||||
if outcome == SelectionInput::Tapped {
|
||||
let byte = field.selection(rsc).byte_at(pos, size);
|
||||
let url = tap_links
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|l| l.range.contains(&byte))
|
||||
.map(|l| l.url.clone());
|
||||
if let Some(url) = url {
|
||||
log::info!("iris link: opening {url}");
|
||||
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
|
||||
}
|
||||
}
|
||||
})
|
||||
.add(rsc);
|
||||
|
||||
let framed = match frame {
|
||||
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
|
||||
BlockFrame::Verbatim { fill } => field
|
||||
.scrollable(Axis::X, Pin::Start)
|
||||
.pad(dp(FRAME_PAD_DP))
|
||||
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||||
.width(rest(1))
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
BlockFrame::Quote => field
|
||||
.width(rest(1))
|
||||
.pad(Padding {
|
||||
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
|
||||
..Padding::ZERO
|
||||
})
|
||||
.background(rect(theme.quote_bar.clone()).width(dp(QUOTE_BAR_DP)))
|
||||
.width(rest(1))
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
};
|
||||
(field, framed, links)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_text_row<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
sender: Option<&str>,
|
||||
markdown_src: &str,
|
||||
cap: bool,
|
||||
theme: Rc<Theme>,
|
||||
) -> (StrongWidget, RowBlocks)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let source = Rc::new(RowSource {
|
||||
sender: sender.map(str::to_string),
|
||||
markdown: markdown_src.to_string(),
|
||||
});
|
||||
let strong = WidgetPtr::new().add_strong(rsc);
|
||||
let ptr = strong.weak();
|
||||
let (content, blocks) = row_content(rsc, list, key, source, ptr, cap, theme);
|
||||
ptr(rsc).set(content);
|
||||
(strong.any(), blocks)
|
||||
}
|
||||
|
||||
/// Separate from [`build_text_row`] because the tap calls it a second
|
||||
/// time, with `cap` false, and writes the result back into the same
|
||||
/// `WidgetPtr`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn row_content<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
source: Rc<RowSource>,
|
||||
ptr: WeakWidget<WidgetPtr>,
|
||||
cap: bool,
|
||||
theme: Rc<Theme>,
|
||||
) -> (StrongWidget, RowBlocks)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let (blocks, hidden) = cap_message(display_blocks(&source.markdown), cap);
|
||||
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
|
||||
let mut fields = Vec::with_capacity(blocks.len());
|
||||
let mut links = Vec::with_capacity(blocks.len());
|
||||
for block in &blocks {
|
||||
let (field, framed, block_links) = build_block(rsc, block, &theme);
|
||||
fields.push(field);
|
||||
links.push(block_links);
|
||||
column.push(framed);
|
||||
}
|
||||
if let Some(lines) = hidden {
|
||||
column.push(show_all(
|
||||
rsc,
|
||||
list,
|
||||
key,
|
||||
source.clone(),
|
||||
ptr,
|
||||
lines,
|
||||
theme.clone(),
|
||||
));
|
||||
}
|
||||
let column = column.add(rsc);
|
||||
|
||||
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded
|
||||
// as a child of the `.span(Dir::DOWN)` below, whose own composition is
|
||||
// what performs the *one* real strong registration each child gets.
|
||||
// Calling `.add_strong`/`.upgrade` here too, then feeding a `.weak()`
|
||||
// copy into that composition, tried to strong-register the same id
|
||||
// twice and panicked with "was already added"
|
||||
// (`core/src/widget/like.rs:12`) -- found running this crate's own
|
||||
// `run-headless.sh` example, the first real render of a row.
|
||||
let header: WeakWidget = match &source.sender {
|
||||
Some(name) => wtext(name.clone())
|
||||
.size(13.0)
|
||||
.color(theme.secondary_text.clone())
|
||||
.add(rsc),
|
||||
None => Span::empty(Dir::DOWN).add(rsc),
|
||||
};
|
||||
|
||||
let widget = (header, column.width(rest(1)))
|
||||
.span(Dir::DOWN)
|
||||
.gap(dp(4))
|
||||
.pad(dp(10))
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
(
|
||||
widget,
|
||||
RowBlocks {
|
||||
blocks,
|
||||
fields,
|
||||
links,
|
||||
column,
|
||||
sender: source.sender.clone(),
|
||||
capped: hidden.is_some(),
|
||||
theme,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The `RowBlocks` the rebuild produces is **discarded**, because a capped
|
||||
/// row is never the row a reply is streaming into (`build_row`'s `cap`) --
|
||||
/// so nothing is holding one for it, and there is nothing to keep in step.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn show_all<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
source: Rc<RowSource>,
|
||||
ptr: WeakWidget<WidgetPtr>,
|
||||
lines: usize,
|
||||
theme: Rc<Theme>,
|
||||
) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let label = show_all_label(lines);
|
||||
let more_strong = WidgetPtr::new().add_strong(rsc);
|
||||
let more = more_strong.weak();
|
||||
let words = wtext(label.clone())
|
||||
.size(13.0)
|
||||
.color(theme.secondary_text.clone())
|
||||
.text_align(Align::LEFT)
|
||||
.label(label)
|
||||
.add_strong(rsc);
|
||||
more(rsc).set(words);
|
||||
on_tap(rsc, more, list, move |rsc| {
|
||||
hold_edge(rsc, list, key);
|
||||
let (content, _blocks) =
|
||||
row_content(rsc, list, key, source.clone(), ptr, false, theme.clone());
|
||||
let _old = ptr(rsc).replace(content);
|
||||
});
|
||||
more_strong.any()
|
||||
}
|
||||
|
||||
impl RowBlocks {
|
||||
/// Bring this row up to date with `markdown_src` **without** re-laying
|
||||
/// out the blocks that did not change, and say whether that was
|
||||
/// possible. `false` means the caller must rebuild the row the
|
||||
/// ordinary way: an earlier block was rewritten (markdown allows it --
|
||||
/// a trailing `---` turns the paragraph above into a heading), the
|
||||
/// sender changed, or the message got shorter.
|
||||
pub fn apply_delta<Rsc: HasEvents>(
|
||||
&mut self,
|
||||
rsc: &mut Rsc,
|
||||
sender: Option<&str>,
|
||||
markdown_src: &str,
|
||||
) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
if self.sender.as_deref() != sender {
|
||||
return false;
|
||||
}
|
||||
// A capped row draws less than the message it was built from, so
|
||||
// appending to it would put the new text *below* the "Show all"
|
||||
// saying the rest is hidden. The caller rebuilds instead, and
|
||||
// rebuilds uncapped (`TranscriptScreen::apply`), so this refusal
|
||||
// costs one rebuild per message rather than one per delta.
|
||||
if self.capped {
|
||||
return false;
|
||||
}
|
||||
let new_blocks = display_blocks(markdown_src);
|
||||
let common = common_prefix(&self.blocks, &new_blocks);
|
||||
// Everything already drawn must either be kept whole (`common ==
|
||||
// len`, a pure append) or be kept except for the last block, which
|
||||
// is the one a delta lands in. Anything else means an already
|
||||
// laid-out block is no longer what it was.
|
||||
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
|
||||
return false;
|
||||
}
|
||||
if new_blocks.len() == self.blocks.len()
|
||||
&& common < self.blocks.len()
|
||||
&& new_blocks[common].kind != self.blocks[common].kind
|
||||
{
|
||||
return false;
|
||||
}
|
||||
debug_assert!(
|
||||
self.fields.len() == self.blocks.len() && self.links.len() == self.blocks.len(),
|
||||
"one field and one link list per block: {} fields, {} links, {} blocks",
|
||||
self.fields.len(),
|
||||
self.links.len(),
|
||||
self.blocks.len()
|
||||
);
|
||||
|
||||
for (i, block) in new_blocks.iter().enumerate().skip(common) {
|
||||
match (self.fields.get(i), self.links.get(i)) {
|
||||
(Some(field), Some(links)) => {
|
||||
let rendered = render_block(block, BASE_SIZE, &self.theme);
|
||||
field(rsc).set_with_spans(rendered.text, rendered.spans);
|
||||
*links.borrow_mut() = rendered.links;
|
||||
}
|
||||
_ => {
|
||||
let (field, framed, links) = build_block(rsc, block, &self.theme);
|
||||
self.fields.push(field);
|
||||
self.links.push(links);
|
||||
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
|
||||
column.push(framed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.blocks = new_blocks;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn build_single<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
item: &TranscriptItem,
|
||||
cap: bool,
|
||||
theme: Rc<Theme>,
|
||||
) -> (StrongWidget, RowBlocks)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let (sender, markdown_src) = item_content(item);
|
||||
build_text_row(rsc, list, key, sender, &markdown_src, cap, theme)
|
||||
}
|
||||
|
||||
/// Two mechanisms would have been two answers to the same question ("what
|
||||
/// can this row do cheaply?"), so the caller holds one of these for its
|
||||
/// tail row and asks it, rather than holding a `RowBlocks` and a
|
||||
/// `ToolRow` and choosing between them at each call site.
|
||||
pub enum TailRow {
|
||||
Blocks(RowBlocks),
|
||||
Tools(ToolRow),
|
||||
}
|
||||
|
||||
/// `cap` draws a long message as [`cap_message`]'s worth of it behind a
|
||||
/// "Show all"; the caller passes `false` for the **live tail**, the row a
|
||||
/// reply is streaming into, because a row that grows while it is capped
|
||||
/// would appear to stop growing (`RowBlocks::capped`). Every other row is
|
||||
/// capped.
|
||||
pub fn build_row<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
row: &FoldedRow,
|
||||
working: bool,
|
||||
cap: bool,
|
||||
theme: Rc<Theme>,
|
||||
) -> (RowKey, StrongWidget, Option<TailRow>)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
// A lone tool call is a card too, not a message with markdown in it:
|
||||
// `group_tool_runs` leaves one call as a `Single` because "Called 1
|
||||
// tool" hides a card to say the same thing in more words, and the
|
||||
// *card* is what both cases draw (`ToolRows.kt`).
|
||||
let calls = match row {
|
||||
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => {
|
||||
Some(std::slice::from_ref(item))
|
||||
}
|
||||
FoldedRow::Tools(calls) => Some(calls.as_slice()),
|
||||
FoldedRow::Single(_) => None,
|
||||
};
|
||||
if let Some(calls) = calls {
|
||||
let key = row_key(&calls[0].key());
|
||||
let (widget, tools) =
|
||||
crate::ui::tool::build_tool_row(rsc, list, key, calls.to_vec(), working, theme);
|
||||
return (key, widget, Some(TailRow::Tools(tools)));
|
||||
}
|
||||
let FoldedRow::Single(item) = row else {
|
||||
unreachable!("every Tools row took the branch above");
|
||||
};
|
||||
let key = row_key(&item.key());
|
||||
let (widget, blocks) = build_single(rsc, list, key, item, cap, theme);
|
||||
(key, widget, Some(TailRow::Blocks(blocks)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn blocks(src: &str) -> Vec<Block> {
|
||||
display_blocks(src)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_inside_the_bounds_is_not_capped() {
|
||||
let (kept, hidden) = cap_message(blocks("hello\n\nthere"), true);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert_eq!(hidden, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_false_keeps_everything() {
|
||||
let src = "a\n\n".repeat(MESSAGE_LINES * 2);
|
||||
let (kept, hidden) = cap_message(blocks(&src), false);
|
||||
assert_eq!(kept.len(), MESSAGE_LINES * 2);
|
||||
assert_eq!(hidden, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_message_is_cut_on_a_block_boundary() {
|
||||
let src = "a paragraph\n\n".repeat(MESSAGE_LINES * 2);
|
||||
let all = blocks(&src);
|
||||
let (kept, hidden) = cap_message(all.clone(), true);
|
||||
assert!(kept.len() < all.len(), "nothing was left out");
|
||||
assert!(
|
||||
kept.iter().zip(&all).all(|(k, a)| k == a),
|
||||
"a block was truncated where a boundary was available",
|
||||
);
|
||||
assert_eq!(
|
||||
hidden,
|
||||
Some(all.iter().map(|b| b.source.lines().count()).sum()),
|
||||
"the offer says the whole message's line count, not the shown part's",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_block_over_the_bound_by_itself_is_truncated() {
|
||||
let src = format!("```\n{}```", "x\n".repeat(MESSAGE_LINES * 2));
|
||||
let all = blocks(&src);
|
||||
assert_eq!(all.len(), 1, "the fixture must be a single block");
|
||||
let (kept, hidden) = cap_message(all.clone(), true);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(
|
||||
kept[0].kind, all[0].kind,
|
||||
"truncation changed the block's kind"
|
||||
);
|
||||
assert!(
|
||||
kept[0].source.len() < all[0].source.len(),
|
||||
"the one over-long block was drawn whole",
|
||||
);
|
||||
assert!(hidden.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use iris::prelude::*;
|
||||
|
||||
pub(crate) fn on_tap<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
ptr: WeakWidget<WidgetPtr>,
|
||||
list: WeakWidget<LazySpan>,
|
||||
f: impl Fn(&mut Rsc) + 'static,
|
||||
) where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
ptr.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||
let input = &ctx.data;
|
||||
let outcome = rsc
|
||||
.with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
|
||||
selection.drag(id, rsc, input)
|
||||
})
|
||||
.unwrap_or(SelectionInput::Tapped);
|
||||
if outcome == SelectionInput::Tapped {
|
||||
f(rsc);
|
||||
}
|
||||
})
|
||||
.add(rsc);
|
||||
}
|
||||
|
||||
pub(crate) fn hold_edge(rsc: &mut impl UiRsc, list: WeakWidget<LazySpan>, key: RowKey) {
|
||||
if let Some((top, _bottom)) = list(rsc).extent(key) {
|
||||
list(rsc).note_tap(top);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use iris::prelude::*;
|
||||
|
||||
/// The shared phone/desktop paint handles. Replacing their paint-table
|
||||
/// entries changes the theme without rebuilding widgets or primitives.
|
||||
#[derive(Clone)]
|
||||
pub struct Theme {
|
||||
pub text: PaintId,
|
||||
pub code: PaintId,
|
||||
pub link: PaintId,
|
||||
pub marker: PaintId,
|
||||
pub verbatim_surface: PaintId,
|
||||
pub table_surface: PaintId,
|
||||
pub quote_bar: PaintId,
|
||||
pub quote_text: PaintId,
|
||||
pub strikethrough: PaintId,
|
||||
pub card_surface: PaintId,
|
||||
pub group_surface: PaintId,
|
||||
pub muted: PaintId,
|
||||
pub awaiting: PaintId,
|
||||
pub failed: PaintId,
|
||||
pub unknown: PaintId,
|
||||
pub composer_surface: PaintId,
|
||||
pub secondary_text: PaintId,
|
||||
pub syntax_keyword: PaintId,
|
||||
pub syntax_string: PaintId,
|
||||
pub syntax_literal: PaintId,
|
||||
pub syntax_comment: PaintId,
|
||||
pub syntax_metadata: PaintId,
|
||||
pub syntax_punctuation: PaintId,
|
||||
pub syntax_mark: PaintId,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn new(paints: &mut Paints) -> Self {
|
||||
Self {
|
||||
text: paints.add(srgb(0xCDD6F4)),
|
||||
code: paints.add(srgb(0xCDD6F4)),
|
||||
link: paints.add(srgb(0x89B4FA)),
|
||||
marker: paints.add(srgb(0xB4BEFE)),
|
||||
verbatim_surface: paints.add(srgb(0x11111B)),
|
||||
table_surface: paints.add(srgb(0x313244)),
|
||||
quote_bar: paints.add(srgb(0x585B70)),
|
||||
quote_text: paints.add(srgb(0xA6ADC8)),
|
||||
strikethrough: paints.add(srgb(0x6C7086)),
|
||||
card_surface: paints.add(srgb(0x313244)),
|
||||
group_surface: paints.add(srgb(0x181825)),
|
||||
muted: paints.add(srgb(0xA6ADC8)),
|
||||
awaiting: paints.add(srgb(0xFAB387)),
|
||||
failed: paints.add(srgb(0xF38BA8)),
|
||||
unknown: paints.add(srgb(0xF9E2AF)),
|
||||
composer_surface: paints.add(Srgba8::rgb(40, 40, 46)),
|
||||
secondary_text: paints.add(Srgba8::rgb(150, 150, 160)),
|
||||
syntax_keyword: paints.add(srgb(0xCBA6F7)),
|
||||
syntax_string: paints.add(srgb(0xA6E3A1)),
|
||||
syntax_literal: paints.add(srgb(0xFAB387)),
|
||||
syntax_comment: paints.add(srgb(0x6C7086)),
|
||||
syntax_metadata: paints.add(srgb(0xF9E2AF)),
|
||||
syntax_punctuation: paints.add(srgb(0xA6ADC8)),
|
||||
syntax_mark: paints.add(srgb(0x89DCEB)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn srgb(hex: u32) -> Srgba8 {
|
||||
Srgba8::rgb(
|
||||
((hex >> 16) & 0xff) as u8,
|
||||
((hex >> 8) & 0xff) as u8,
|
||||
(hex & 0xff) as u8,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
use crate::client::text_cap::{VERBATIM_BYTES, VERBATIM_LINES, cut, show_all_label};
|
||||
use crate::client::tool_summary::{ToolInput, parse_tool_input};
|
||||
use crate::client::transcript_fold::{ToolState, TranscriptItem};
|
||||
use crate::ui::markdown::highlight_into;
|
||||
use crate::ui::tap::{hold_edge, on_tap};
|
||||
use crate::ui::theme::Theme;
|
||||
use iris::prelude::*;
|
||||
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
|
||||
|
||||
const NAME_SIZE: f32 = 14.0;
|
||||
const BODY_SIZE: f32 = 12.0;
|
||||
const LABEL_SIZE: f32 = 11.0;
|
||||
|
||||
const CARD_PAD_DP: f32 = 12.0;
|
||||
const CARD_RADIUS_DP: f32 = 12.0;
|
||||
const GAP_DP: f32 = 8.0;
|
||||
const RAW_RADIUS_DP: f32 = 4.0;
|
||||
const RAW_PAD_DP: f32 = 8.0;
|
||||
const GROUP_INSET_DP: f32 = 4.0;
|
||||
|
||||
const MARK_DP: f32 = 9.0;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ToolRowState {
|
||||
group_expanded: bool,
|
||||
open: HashMap<String, bool>,
|
||||
whole: HashMap<(String, Part), bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
enum Part {
|
||||
Input,
|
||||
Output,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
calls: RefCell<Vec<TranscriptItem>>,
|
||||
state: RefCell<ToolRowState>,
|
||||
/// One `WidgetPtr` per call, in order -- what makes a result cost one
|
||||
/// card. Empty while the group is collapsed, because a collapsed group
|
||||
/// draws no cards at all. Its path out is [`build_content`], which
|
||||
/// clears it before building whatever replaces them.
|
||||
cards: RefCell<Vec<WeakWidget<WidgetPtr>>>,
|
||||
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
working: Cell<bool>,
|
||||
theme: Rc<Theme>,
|
||||
}
|
||||
|
||||
/// One transcript row's worth of tool calls, kept by the caller for the
|
||||
/// row a result can still land in -- the tool-call counterpart of
|
||||
/// [`crate::ui::row::RowBlocks`], and the reason a `ToolEnd` costs one card
|
||||
/// rather than a row.
|
||||
pub struct ToolRow {
|
||||
shared: Rc<Shared>,
|
||||
}
|
||||
|
||||
fn text<Rsc>(content: impl Into<String>, size: f32, color: PaintId) -> TextBuilder<Rsc> {
|
||||
wtext(content)
|
||||
.size(size)
|
||||
.color(color)
|
||||
.text_align(Align::LEFT)
|
||||
}
|
||||
|
||||
fn disclosure<Rsc>(glyph: &'static str, theme: &Theme) -> TextBuilder<Rsc> {
|
||||
text(glyph, MARK_DP, theme.muted.clone()).family(Family::Icons)
|
||||
}
|
||||
|
||||
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>, theme: &Theme) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
let field = body
|
||||
.family(Family::Monospace)
|
||||
.size(BODY_SIZE)
|
||||
.wrap(false)
|
||||
.add(rsc);
|
||||
field
|
||||
.scrollable(Axis::X, Pin::Start)
|
||||
.pad(dp(RAW_PAD_DP))
|
||||
.masked_by(rect(theme.verbatim_surface.clone()).radius(dp(RAW_RADIUS_DP)))
|
||||
.width(rest(1))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
fn state_word(state: ToolState) -> Option<&'static str> {
|
||||
match state {
|
||||
ToolState::Deciding => Some("your turn"),
|
||||
ToolState::Running => Some("running"),
|
||||
ToolState::Failed => Some("failed"),
|
||||
ToolState::NoResult => Some("no result"),
|
||||
ToolState::Succeeded => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_mark(state: ToolState, theme: &Theme) -> Option<(&'static str, PaintId)> {
|
||||
let color = match state {
|
||||
ToolState::Deciding => theme.awaiting.clone(),
|
||||
ToolState::Running => theme.muted.clone(),
|
||||
ToolState::Failed => theme.failed.clone(),
|
||||
ToolState::NoResult => theme.unknown.clone(),
|
||||
ToolState::Succeeded => return None,
|
||||
};
|
||||
Some((
|
||||
state_word(state).expect("non-success state has a label"),
|
||||
color,
|
||||
))
|
||||
}
|
||||
|
||||
/// What a screen reader is given for one card, and what a `ui-trace`
|
||||
/// script taps by: the tool, what the call is for, and how it went when
|
||||
/// that is anything but "fine" -- the same three things the Compose card's
|
||||
/// own text says, in the order it says them.
|
||||
fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String {
|
||||
let mut name = tool.to_string();
|
||||
if let Some(title) = parsed.title() {
|
||||
name.push_str(": ");
|
||||
name.push_str(title);
|
||||
}
|
||||
if let Some(word) = state_word(state) {
|
||||
name.push_str(" (");
|
||||
name.push_str(word);
|
||||
name.push(')');
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// The heading a group carries, closed or open. Compose's exact wording,
|
||||
/// because it is also the name every `ui-trace` script taps it by.
|
||||
fn group_label(count: usize) -> String {
|
||||
format!("Called {count} tools")
|
||||
}
|
||||
|
||||
fn capped(body: &str, whole: bool) -> (&str, usize, bool) {
|
||||
match cut(body, VERBATIM_LINES, VERBATIM_BYTES) {
|
||||
Some((head, lines)) if !whole => (head, lines, true),
|
||||
Some((_, lines)) => (body, lines, false),
|
||||
None => (body, body.lines().count(), false),
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_whole(shared: &Shared, id: &str, part: Part) -> bool {
|
||||
shared
|
||||
.state
|
||||
.borrow()
|
||||
.whole
|
||||
.get(&(id.to_string(), part))
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A control rather than a note, and it says the count rather than "more",
|
||||
/// because the reader is deciding whether to ask for it: "Show all 4,000
|
||||
/// lines" and "Show all 12 lines" are different decisions and the word
|
||||
/// "more" tells them apart not at all.
|
||||
fn show_all<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
shared: &Rc<Shared>,
|
||||
index: usize,
|
||||
id: &str,
|
||||
part: Part,
|
||||
lines: usize,
|
||||
) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let label = show_all_label(lines);
|
||||
let more_strong = WidgetPtr::new().add_strong(rsc);
|
||||
let more = more_strong.weak();
|
||||
let words = text(label.clone(), LABEL_SIZE, shared.theme.muted.clone())
|
||||
.label(label)
|
||||
.add_strong(rsc);
|
||||
more(rsc).set(words);
|
||||
let shared_for_tap = shared.clone();
|
||||
let key = (id.to_string(), part);
|
||||
on_tap(rsc, more, shared.list, move |rsc| {
|
||||
hold_edge(rsc, shared_for_tap.list, shared_for_tap.key);
|
||||
shared_for_tap
|
||||
.state
|
||||
.borrow_mut()
|
||||
.whole
|
||||
.insert(key.clone(), true);
|
||||
redraw_card(rsc, &shared_for_tap, index);
|
||||
});
|
||||
more_strong.any()
|
||||
}
|
||||
|
||||
fn output_block<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
shared: &Rc<Shared>,
|
||||
index: usize,
|
||||
id: &str,
|
||||
output: &str,
|
||||
call_state: ToolState,
|
||||
) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
if output.is_empty() {
|
||||
let (words, colour) = match call_state {
|
||||
ToolState::Succeeded => ("No output", shared.theme.muted.clone()),
|
||||
ToolState::Failed => ("Failed, with no output", shared.theme.failed.clone()),
|
||||
ToolState::NoResult => ("No result ever arrived", shared.theme.unknown.clone()),
|
||||
ToolState::Running | ToolState::Deciding => {
|
||||
("No output yet", shared.theme.muted.clone())
|
||||
}
|
||||
};
|
||||
return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
|
||||
}
|
||||
|
||||
let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output));
|
||||
let mut column = Span::empty(Dir::DOWN).gap(dp(2));
|
||||
column.push(
|
||||
text("Output", LABEL_SIZE, shared.theme.text.clone())
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone());
|
||||
column.push(raw_block(rsc, body, &shared.theme));
|
||||
if was_cut {
|
||||
column.push(show_all(rsc, shared, index, id, Part::Output, lines));
|
||||
}
|
||||
column.width(rest(1)).add_strong(rsc).any()
|
||||
}
|
||||
|
||||
fn build_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let call = shared.calls.borrow()[index].clone();
|
||||
let TranscriptItem::ToolRun {
|
||||
id,
|
||||
tool,
|
||||
input,
|
||||
output,
|
||||
..
|
||||
} = &call
|
||||
else {
|
||||
debug_assert!(false, "a tool row holds only tool calls, not {call:?}");
|
||||
return Span::empty(Dir::DOWN).add_strong(rsc).any();
|
||||
};
|
||||
let parsed = parse_tool_input(tool, input);
|
||||
let call_state = ToolState::of(&call, shared.working.get()).expect("matched ToolRun above");
|
||||
let open = shared.state.borrow().open.get(id).copied().unwrap_or(false)
|
||||
|| call_state == ToolState::Deciding;
|
||||
|
||||
let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP));
|
||||
header.push(
|
||||
disclosure(if open { icon::OPEN } else { icon::CLOSED }, &shared.theme)
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
header.push(
|
||||
text(tool.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
match (open, parsed.title()) {
|
||||
(true, _) | (false, None) => {
|
||||
header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any())
|
||||
}
|
||||
(false, Some(title)) => header.push(
|
||||
text(title.to_string(), BODY_SIZE, shared.theme.muted.clone())
|
||||
.wrap(false)
|
||||
.masked()
|
||||
.width(rest(1))
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
),
|
||||
}
|
||||
if open && let Some(timeout) = &parsed.timeout {
|
||||
header.push(
|
||||
text(
|
||||
format!("timeout {timeout}"),
|
||||
LABEL_SIZE,
|
||||
shared.theme.muted.clone(),
|
||||
)
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
}
|
||||
if let Some((word, colour)) = state_mark(call_state, &shared.theme) {
|
||||
header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any());
|
||||
}
|
||||
|
||||
let mut column = Span::empty(Dir::DOWN).gap(dp(GAP_DP / 2.0));
|
||||
column.push(header.width(rest(1)).add_strong(rsc).any());
|
||||
if open {
|
||||
if let Some(description) = &parsed.description {
|
||||
column.push(
|
||||
text(description.clone(), BODY_SIZE, shared.theme.muted.clone())
|
||||
.width(rest(1))
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
}
|
||||
let whole = wants_whole(shared, id, Part::Input);
|
||||
let mut input_lines = 0usize;
|
||||
let mut input_cut = false;
|
||||
if let Some(subject) = &parsed.subject {
|
||||
let (shown, lines, was_cut) = capped(subject, whole);
|
||||
input_lines += lines;
|
||||
input_cut |= was_cut;
|
||||
let spans = match parsed.language {
|
||||
Some(language) => {
|
||||
let mut spans = Vec::new();
|
||||
highlight_into(&mut spans, shown, 0..shown.len(), language, &shared.theme);
|
||||
spans
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone()).spans(spans);
|
||||
column.push(raw_block(rsc, body, &shared.theme));
|
||||
}
|
||||
if !parsed.rest.is_empty() {
|
||||
// Never dropped: a field left out would be claiming the tool
|
||||
// had no other input when it might (`ToolInput.kt`). Capped is
|
||||
// not dropped -- the field is still there, with its size said
|
||||
// out loud.
|
||||
let joined = parsed.rest.join("\n");
|
||||
let (shown, lines, was_cut) = capped(&joined, whole);
|
||||
input_lines += lines;
|
||||
input_cut |= was_cut;
|
||||
let body = text(shown.to_string(), BODY_SIZE, shared.theme.muted.clone());
|
||||
column.push(raw_block(rsc, body, &shared.theme));
|
||||
}
|
||||
if input_cut {
|
||||
column.push(show_all(rsc, shared, index, id, Part::Input, input_lines));
|
||||
}
|
||||
column.push(output_block(rsc, shared, index, id, output, call_state));
|
||||
}
|
||||
|
||||
column
|
||||
.width(rest(1))
|
||||
.pad(dp(CARD_PAD_DP))
|
||||
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||
.width(rest(1))
|
||||
.label(card_label(tool, &parsed, call_state))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
fn redraw_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let Some(ptr) = shared.card_ptr(index) else {
|
||||
debug_assert!(false, "card {index} has no widget to redraw");
|
||||
return;
|
||||
};
|
||||
let content = build_card(rsc, shared, index);
|
||||
let _old = ptr(rsc).replace(content);
|
||||
}
|
||||
|
||||
fn build_card_ptr<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
shared: &Rc<Shared>,
|
||||
index: usize,
|
||||
) -> (StrongWidget, WeakWidget<WidgetPtr>)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let strong = WidgetPtr::new().add_strong(rsc);
|
||||
let ptr = strong.weak();
|
||||
shared.cards.borrow_mut().push(ptr);
|
||||
debug_assert_eq!(
|
||||
shared.cards.borrow().len(),
|
||||
index + 1,
|
||||
"a card's index is its position, and both are the call's"
|
||||
);
|
||||
let content = build_card(rsc, shared, index);
|
||||
ptr(rsc).set(content);
|
||||
let for_tap = shared.clone();
|
||||
on_tap(rsc, ptr, shared.list, move |rsc| {
|
||||
hold_edge(rsc, for_tap.list, for_tap.key);
|
||||
let Some(id) = for_tap.call_id(index) else {
|
||||
debug_assert!(false, "tapped card {index} is no longer in the row");
|
||||
return;
|
||||
};
|
||||
let was = for_tap
|
||||
.state
|
||||
.borrow()
|
||||
.open
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
for_tap.state.borrow_mut().open.insert(id, !was);
|
||||
redraw_card(rsc, &for_tap, index);
|
||||
});
|
||||
(strong.any(), ptr)
|
||||
}
|
||||
|
||||
fn collapse_bar<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let strong = WidgetPtr::new().add_strong(rsc);
|
||||
let ptr = strong.weak();
|
||||
let mark = disclosure(icon::COLLAPSE, &shared.theme)
|
||||
.center_text()
|
||||
.width(rest(1))
|
||||
.pad(dp(CARD_PAD_DP))
|
||||
// Anything shown only as a mark still needs a name: this is what
|
||||
// a screen reader reads and what a `ui-trace` script taps.
|
||||
.label("Collapse these tool calls")
|
||||
.add_strong(rsc);
|
||||
ptr(rsc).set(mark);
|
||||
let for_tap = shared.clone();
|
||||
on_tap(rsc, ptr, shared.list, move |rsc| {
|
||||
toggle_group(rsc, &for_tap)
|
||||
});
|
||||
strong.any()
|
||||
}
|
||||
|
||||
/// Rebuilt whole when the group opens or closes, because that is a change
|
||||
/// of what the row *is* rather than of one card in it. Everything a single
|
||||
/// card's tap does goes through [`redraw_card`] instead.
|
||||
fn build_content<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
shared.cards.borrow_mut().clear();
|
||||
let count = shared.calls.borrow().len();
|
||||
debug_assert!(count > 0, "a tool row with no calls has nothing to draw");
|
||||
|
||||
if count == 1 {
|
||||
return build_card_ptr(rsc, shared, 0).0;
|
||||
}
|
||||
|
||||
if !shared.state.borrow().group_expanded {
|
||||
let heading = group_label(count);
|
||||
return text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||
.pad(dp(CARD_PAD_DP))
|
||||
.width(rest(1))
|
||||
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||
.width(rest(1))
|
||||
.label(heading)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
}
|
||||
|
||||
let heading = group_label(count);
|
||||
let mut group = Span::empty(Dir::DOWN);
|
||||
group.push(
|
||||
text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||
.pad(dp(CARD_PAD_DP))
|
||||
.width(rest(1))
|
||||
.label(heading)
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
{
|
||||
let mut cards = Span::empty(Dir::DOWN);
|
||||
for index in 0..count {
|
||||
cards.push(build_card_ptr(rsc, shared, index).0);
|
||||
}
|
||||
group.push(cards.pad(dp(GROUP_INSET_DP)).add_strong(rsc).any());
|
||||
}
|
||||
group.push(collapse_bar(rsc, shared));
|
||||
group
|
||||
.width(rest(1))
|
||||
.background(rect(shared.theme.group_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||
.width(rest(1))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
fn toggle_group<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
hold_edge(rsc, shared.list, shared.key);
|
||||
let was = shared.state.borrow().group_expanded;
|
||||
shared.state.borrow_mut().group_expanded = !was;
|
||||
let content = build_content(rsc, shared);
|
||||
shared.set_content(rsc, content);
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
fn set_content(&self, rsc: &mut impl UiRsc, content: StrongWidget) {
|
||||
let Some(ptr) = *self.content.borrow() else {
|
||||
debug_assert!(
|
||||
false,
|
||||
"the row's content pointer is set before anything can tap it"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let _old = ptr(rsc).replace(content);
|
||||
}
|
||||
|
||||
fn call_id(&self, index: usize) -> Option<String> {
|
||||
match self.calls.borrow().get(index) {
|
||||
Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn card_ptr(&self, index: usize) -> Option<WeakWidget<WidgetPtr>> {
|
||||
self.cards.borrow().get(index).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// `working` is the caller's `session_working` **for this row** -- true
|
||||
/// only for the newest row of a session that is still doing something.
|
||||
/// Every row behind it belongs to a turn that has ended, so a call in one
|
||||
/// with no result never came back rather than still running.
|
||||
pub fn build_tool_row<Rsc: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<LazySpan>,
|
||||
key: RowKey,
|
||||
calls: Vec<TranscriptItem>,
|
||||
working: bool,
|
||||
theme: Rc<Theme>,
|
||||
) -> (StrongWidget, ToolRow)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let shared = Rc::new(Shared {
|
||||
calls: RefCell::new(calls),
|
||||
state: RefCell::new(ToolRowState::default()),
|
||||
cards: RefCell::new(Vec::new()),
|
||||
content: RefCell::new(None),
|
||||
list,
|
||||
key,
|
||||
working: Cell::new(working),
|
||||
theme,
|
||||
});
|
||||
let content_strong = WidgetPtr::new().add_strong(rsc);
|
||||
let content = content_strong.weak();
|
||||
*shared.content.borrow_mut() = Some(content);
|
||||
let inner = build_content(rsc, &shared);
|
||||
content(rsc).set(inner);
|
||||
(content_strong.any(), ToolRow { shared })
|
||||
}
|
||||
|
||||
impl ToolRow {
|
||||
/// The calls this row is currently drawing -- what a caller passes
|
||||
/// back to [`Self::apply_calls`] when something other than the calls
|
||||
/// themselves changed (the session's status).
|
||||
pub fn calls(&self) -> Vec<TranscriptItem> {
|
||||
self.shared.calls.borrow().clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn card_count(&self) -> usize {
|
||||
self.shared.cards.borrow().len()
|
||||
}
|
||||
|
||||
/// Exists because the expanded appearance is otherwise unreachable
|
||||
/// from anything that cannot press the screen -- a headless
|
||||
/// screenshot on this displayless machine, and a test. Same path a tap
|
||||
/// takes, including `LazySpan::note_tap`, so what it produces is what a
|
||||
/// reader would have got.
|
||||
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
if self.shared.state.borrow().group_expanded != expanded {
|
||||
toggle_group(rsc, &self.shared);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring this row up to date with `calls` **without** rebuilding the
|
||||
/// cards that did not change, and say whether that was possible.
|
||||
/// `false` means the caller must rebuild the row the ordinary way.
|
||||
pub fn apply_calls<Rsc: HasEvents>(
|
||||
&mut self,
|
||||
rsc: &mut Rsc,
|
||||
calls: &[TranscriptItem],
|
||||
working: bool,
|
||||
) -> bool
|
||||
where
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
if calls.is_empty()
|
||||
|| !calls
|
||||
.iter()
|
||||
.all(|c| matches!(c, TranscriptItem::ToolRun { .. }))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let old = self.shared.calls.borrow().clone();
|
||||
if calls.len() < old.len() {
|
||||
return false;
|
||||
}
|
||||
if (old.len() == 1) != (calls.len() == 1) {
|
||||
return false;
|
||||
}
|
||||
let changed: Vec<usize> = (0..old.len()).filter(|&i| old[i] != calls[i]).collect();
|
||||
self.shared.working.set(working);
|
||||
*self.shared.calls.borrow_mut() = calls.to_vec();
|
||||
let ids: std::collections::HashSet<String> = calls
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
TranscriptItem::ToolRun { id, .. } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
{
|
||||
let mut state = self.shared.state.borrow_mut();
|
||||
state.open.retain(|id, _| ids.contains(id));
|
||||
state.whole.retain(|(id, _), _| ids.contains(id));
|
||||
}
|
||||
|
||||
if self.shared.cards.borrow().is_empty() {
|
||||
if calls.len() != old.len() {
|
||||
let content = build_content(rsc, &self.shared);
|
||||
self.shared.set_content(rsc, content);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
debug_assert_eq!(
|
||||
self.shared.cards.borrow().len(),
|
||||
old.len(),
|
||||
"an open row draws exactly one card per call"
|
||||
);
|
||||
|
||||
for index in changed {
|
||||
redraw_card(rsc, &self.shared, index);
|
||||
}
|
||||
if calls.len() > old.len() {
|
||||
let content = build_content(rsc, &self.shared);
|
||||
self.shared.set_content(rsc, content);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::{Harness, TouchAction, TouchScript};
|
||||
use iris::prelude::*;
|
||||
use iris::sense::DRAG_SLOP;
|
||||
|
||||
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
(h, opened.screen)
|
||||
}
|
||||
|
||||
fn tracked_row(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> (RowKey, f32) {
|
||||
let middle = phone_size().y / 2.0;
|
||||
let list = (screen.list)(&mut h.rsc);
|
||||
let key = list.key_at(middle).expect("a row under the viewport");
|
||||
let (top, _) = list.extent(key).expect("that row has an extent");
|
||||
(key, top)
|
||||
}
|
||||
|
||||
fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 {
|
||||
(screen.list)(&mut h.rsc)
|
||||
.extent(key)
|
||||
.expect("the tracked row is still loaded")
|
||||
.0
|
||||
}
|
||||
|
||||
const STEP: f32 = 2.0;
|
||||
const SAMPLES: usize = 3;
|
||||
const CATCH_X: f32 = 540.0;
|
||||
|
||||
fn drag_from(
|
||||
h: &mut Harness,
|
||||
screen: &ai_app::ui::TranscriptScreen,
|
||||
key: RowKey,
|
||||
y0: f32,
|
||||
t0: u64,
|
||||
expect_tracking: bool,
|
||||
) -> u64 {
|
||||
let before = row_top(h, screen, key);
|
||||
h.touch(TouchAction::Down, Vec2::new(CATCH_X, y0), t0);
|
||||
assert_eq!(
|
||||
row_top(h, screen, key),
|
||||
before,
|
||||
"the down itself must not move the content, only stop it"
|
||||
);
|
||||
let mut t = t0;
|
||||
for i in 1..=SAMPLES {
|
||||
let moved = STEP * i as f32;
|
||||
t = t0 + 8 * i as u64;
|
||||
h.touch(TouchAction::Move, Vec2::new(CATCH_X, y0 + moved), t);
|
||||
let travelled = row_top(h, screen, key) - before;
|
||||
if expect_tracking {
|
||||
assert!(
|
||||
(travelled - moved).abs() < 0.5,
|
||||
"sample {i}: the finger has moved {moved}px since the down and the content \
|
||||
{travelled:.1}px -- it is not pinned to the finger"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
travelled.abs() < 0.5,
|
||||
"sample {i}: a {moved}px drag is inside DRAG_SLOP ({DRAG_SLOP}px) and must move \
|
||||
nothing, but the content moved {travelled:.1}px"
|
||||
);
|
||||
}
|
||||
}
|
||||
t += 8;
|
||||
h.touch(
|
||||
TouchAction::Up,
|
||||
Vec2::new(CATCH_X, y0 + STEP * SAMPLES as f32),
|
||||
t,
|
||||
);
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
|
||||
let (mut h, screen) = opened();
|
||||
|
||||
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||
h.replay(&flick);
|
||||
|
||||
let catch_at = flick.end_ms() + 150;
|
||||
h.frames_until(
|
||||
flick.end_ms() + PHONE_FRAME_MS,
|
||||
catch_at - PHONE_FRAME_MS,
|
||||
PHONE_FRAME_MS,
|
||||
);
|
||||
assert!(
|
||||
(screen.list)(&mut h.rsc).is_scrolling(),
|
||||
"the fling must still be running 150ms in, or this test catches nothing"
|
||||
);
|
||||
|
||||
let (key, _) = tracked_row(&mut h, &screen);
|
||||
drag_from(&mut h, &screen, key, 1200.0, catch_at, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
|
||||
let (mut h, screen) = opened();
|
||||
|
||||
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||
h.replay(&flick);
|
||||
let catch_at = flick.end_ms() + 150;
|
||||
h.frames_until(
|
||||
flick.end_ms() + PHONE_FRAME_MS,
|
||||
catch_at - PHONE_FRAME_MS,
|
||||
PHONE_FRAME_MS,
|
||||
);
|
||||
assert!(
|
||||
(screen.list)(&mut h.rsc).is_scrolling(),
|
||||
"the fling must still be running 150ms in, or this test catches nothing"
|
||||
);
|
||||
|
||||
let (key, _) = tracked_row(&mut h, &screen);
|
||||
h.touch(TouchAction::Down, Vec2::new(CATCH_X, 1200.0), catch_at);
|
||||
let stopped_at = row_top(&mut h, &screen, key);
|
||||
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
|
||||
|
||||
assert_eq!(
|
||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||
None,
|
||||
"a press that stopped a fling and moved nothing must not start another"
|
||||
);
|
||||
h.frames_until(catch_at + 16, catch_at + 500, PHONE_FRAME_MS);
|
||||
assert!(
|
||||
(row_top(&mut h, &screen, key) - stopped_at).abs() < 0.5,
|
||||
"the content moved after a catch was released without moving"
|
||||
);
|
||||
assert_eq!(
|
||||
h.state.opened_urls,
|
||||
Vec::<String>::new(),
|
||||
"a catch is not a tap: nothing under it may be followed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_small_drag_on_a_settled_list_moves_nothing() {
|
||||
let (mut h, screen) = opened();
|
||||
|
||||
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||
h.replay(&flick);
|
||||
let settled = h.frames_until(
|
||||
flick.end_ms() + PHONE_FRAME_MS,
|
||||
flick.end_ms() + 4000,
|
||||
PHONE_FRAME_MS,
|
||||
);
|
||||
assert!(
|
||||
!(screen.list)(&mut h.rsc).is_scrolling(),
|
||||
"the fling must have stopped, or this is the same case as the test above"
|
||||
);
|
||||
|
||||
let (key, _) = tracked_row(&mut h, &screen);
|
||||
drag_from(
|
||||
&mut h,
|
||||
&screen,
|
||||
key,
|
||||
1200.0,
|
||||
settled + PHONE_FRAME_MS,
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::{Harness, TouchAction};
|
||||
use iris::prelude::*;
|
||||
|
||||
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
|
||||
h.render
|
||||
.active
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&id| {
|
||||
h.rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_dyn(id)
|
||||
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||
.is_some_and(|s| s.axis() == Axis::X)
|
||||
})
|
||||
.find_map(|id| {
|
||||
let r = h.render.window_region(&id, &h.rsc)?;
|
||||
(r.top_left.y >= top && r.bot_right.y <= bottom).then_some((id, r))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
|
||||
h.rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_dyn(id)
|
||||
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||
.expect("the fence's scroll area is still drawn")
|
||||
.is_scrolling()
|
||||
}
|
||||
|
||||
fn amt(h: &Harness, id: WidgetId) -> f32 {
|
||||
h.rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_dyn(id)
|
||||
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||
.expect("the fence's scroll area is still drawn")
|
||||
.amt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
|
||||
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||
let screen = opened.screen;
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
|
||||
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||
seq: 9_000_000,
|
||||
text: "```\none two three four five six seven eight nine ten eleven twelve \
|
||||
thirteen fourteen fifteen sixteen seventeen eighteen twenty twentyone\n```"
|
||||
.to_string(),
|
||||
settled: true,
|
||||
});
|
||||
screen.push_row(&mut h.rsc, &fence);
|
||||
(screen.list)(&mut h.rsc).jump_to_end();
|
||||
h.frame(100);
|
||||
h.frame(108);
|
||||
|
||||
let key = ai_app::ui::row::row_key(&fence.key());
|
||||
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||
.extent(key)
|
||||
.expect("the fence row is on screen");
|
||||
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
||||
.expect("the pushed fence draws a horizontal scroll area of its own");
|
||||
assert_eq!(amt(&h, fence_scroll), 0.0, "a fence opens at its start");
|
||||
|
||||
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
||||
|
||||
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
||||
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
||||
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||
}
|
||||
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
||||
|
||||
let at_release = amt(&h, fence_scroll);
|
||||
assert!(
|
||||
at_release > 0.0,
|
||||
"the flick itself must have panned the fence, got {at_release}"
|
||||
);
|
||||
|
||||
let mut t = 240;
|
||||
while t <= 740 {
|
||||
h.frame(t);
|
||||
t += PHONE_FRAME_MS;
|
||||
}
|
||||
let coasted = amt(&h, fence_scroll);
|
||||
assert!(
|
||||
coasted > at_release + 1.0,
|
||||
"the fence stopped dead at the release: {at_release} -> {coasted}"
|
||||
);
|
||||
|
||||
let settled = coasted;
|
||||
while t <= 4_000 {
|
||||
h.frame(t);
|
||||
t += PHONE_FRAME_MS;
|
||||
}
|
||||
let after = amt(&h, fence_scroll);
|
||||
assert!(
|
||||
after >= settled,
|
||||
"a fling must not run backwards: {settled} -> {after}"
|
||||
);
|
||||
let last = after;
|
||||
h.frame(t);
|
||||
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
|
||||
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||
let screen = opened.screen;
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
|
||||
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||
seq: 9_000_000,
|
||||
text: format!(
|
||||
"```\n{}\n```",
|
||||
(1..=200)
|
||||
.map(|i| format!("word{i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
),
|
||||
settled: true,
|
||||
});
|
||||
screen.push_row(&mut h.rsc, &fence);
|
||||
(screen.list)(&mut h.rsc).jump_to_end();
|
||||
h.frame(100);
|
||||
h.frame(108);
|
||||
|
||||
let key = ai_app::ui::row::row_key(&fence.key());
|
||||
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||
.extent(key)
|
||||
.expect("the fence row is on screen");
|
||||
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
||||
.expect("the pushed fence draws a horizontal scroll area of its own");
|
||||
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
||||
|
||||
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
||||
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
||||
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||
}
|
||||
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
||||
h.frame(248);
|
||||
assert!(
|
||||
is_scrolling(&h, fence_scroll),
|
||||
"the fence has to still be coasting for this to be the reported case",
|
||||
);
|
||||
|
||||
let probe = box_.top_left.y - 500.0;
|
||||
let row = (screen.list)(&mut h.rsc)
|
||||
.key_at(probe)
|
||||
.expect("a row that far up the screen");
|
||||
let (row_top, row_bottom) = (screen.list)(&mut h.rsc).extent(row).expect("its extent");
|
||||
let from = (row_top + row_bottom) / 2.0;
|
||||
|
||||
let list_before = (screen.list)(&mut h.rsc).anchor_position_display();
|
||||
let fence_before = amt(&h, fence_scroll);
|
||||
h.touch(TouchAction::Down, Vec2::new(540.0, from), 256);
|
||||
let mut t = 264;
|
||||
for i in 1..=8 {
|
||||
h.touch(
|
||||
TouchAction::Move,
|
||||
Vec2::new(540.0, from + 20.0 * i as f32),
|
||||
t,
|
||||
);
|
||||
t += 8;
|
||||
}
|
||||
h.touch(TouchAction::Up, Vec2::new(540.0, from + 160.0), t);
|
||||
|
||||
assert_ne!(
|
||||
list_before,
|
||||
(screen.list)(&mut h.rsc).anchor_position_display(),
|
||||
"the drag was nowhere near the fence, so it belongs to the list",
|
||||
);
|
||||
assert!(
|
||||
amt(&h, fence_scroll) > fence_before,
|
||||
"the fence's fling must carry on through a gesture that was never \
|
||||
its own: {fence_before} -> {}",
|
||||
amt(&h, fence_scroll),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::{Harness, TouchAction, TouchScript};
|
||||
use iris::prelude::*;
|
||||
|
||||
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
(h, opened.screen)
|
||||
}
|
||||
|
||||
fn script(name: &str, text: &str) -> TouchScript {
|
||||
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||
}
|
||||
|
||||
fn tracked_row(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> (RowKey, f32) {
|
||||
let middle = phone_size().y / 2.0;
|
||||
let list = (screen.list)(&mut h.rsc);
|
||||
let key = list.key_at(middle).expect("a row under the viewport");
|
||||
let (top, _) = list.extent(key).expect("that row has an extent");
|
||||
(key, top)
|
||||
}
|
||||
|
||||
fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 {
|
||||
(screen.list)(&mut h.rsc)
|
||||
.extent(key)
|
||||
.expect("the tracked row is still loaded")
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_flick_does_not_fling() {
|
||||
let (mut h, screen) = opened();
|
||||
let flick = script(
|
||||
"flick-cancelled",
|
||||
include_str!("../touch/flick-cancelled.touch"),
|
||||
);
|
||||
h.replay(&flick);
|
||||
|
||||
assert_eq!(
|
||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||
None,
|
||||
"a gesture the platform took away must not fling"
|
||||
);
|
||||
|
||||
let (key, settled) = tracked_row(&mut h, &screen);
|
||||
let end = flick.end_ms() + 1_000;
|
||||
let mut t = flick.end_ms();
|
||||
while t <= end {
|
||||
h.frame(t);
|
||||
t += PHONE_FRAME_MS;
|
||||
}
|
||||
let now = row_top(&mut h, &screen, key);
|
||||
assert!(
|
||||
(now - settled).abs() < 0.5,
|
||||
"the list kept moving after a cancelled gesture: {settled} -> {now}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
|
||||
let (mut h, screen) = opened();
|
||||
|
||||
h.touch(TouchAction::Down, Vec2::new(540.0, 700.0), 0);
|
||||
h.touch(TouchAction::Cancel, Vec2::new(540.0, 700.0), 8);
|
||||
|
||||
let (key, before) = tracked_row(&mut h, &screen);
|
||||
|
||||
h.touch(TouchAction::Down, Vec2::new(540.0, 1900.0), 200);
|
||||
h.touch(TouchAction::Up, Vec2::new(540.0, 1900.0), 250);
|
||||
|
||||
let after = row_top(&mut h, &screen, key);
|
||||
assert!(
|
||||
(after - before).abs() < 0.5,
|
||||
"a tap after a cancelled press panned the list by {}px, the distance between them",
|
||||
after - before
|
||||
);
|
||||
assert_eq!(
|
||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||
None,
|
||||
"and it must not have flung either"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
|
||||
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||
|
||||
let (mut h, screen) = opened();
|
||||
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||
seq: 9_000_000,
|
||||
text: "```\none two three four five six seven eight nine ten eleven twelve\n\
|
||||
thirteen fourteen fifteen sixteen seventeen eighteen nineteen\n```"
|
||||
.to_string(),
|
||||
settled: true,
|
||||
});
|
||||
let para = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||
seq: 9_000_001,
|
||||
text: "A plain paragraph with nothing to tap in it, only words, so that a \
|
||||
press here is a press on ordinary text and nothing else."
|
||||
.to_string(),
|
||||
settled: true,
|
||||
});
|
||||
screen.push_row(&mut h.rsc, &fence);
|
||||
screen.push_row(&mut h.rsc, ¶);
|
||||
(screen.list)(&mut h.rsc).jump_to_end();
|
||||
h.frame(100);
|
||||
h.frame(108);
|
||||
|
||||
let key = ai_app::ui::row::row_key(&fence.key());
|
||||
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||
.extent(key)
|
||||
.expect("the fence row is on screen");
|
||||
let y = (top + bottom) / 2.0;
|
||||
assert!(
|
||||
y > 0.0 && y < phone_size().y,
|
||||
"the fence row has to be on screen to be pressed: {top}..{bottom}"
|
||||
);
|
||||
|
||||
h.touch(TouchAction::Down, Vec2::new(800.0, y), 200);
|
||||
for (i, x) in [760.0, 700.0, 620.0, 540.0].into_iter().enumerate() {
|
||||
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||
}
|
||||
h.touch(TouchAction::Up, Vec2::new(540.0, y), 248);
|
||||
|
||||
let (tracked, before) = tracked_row(&mut h, &screen);
|
||||
|
||||
let para_key = ai_app::ui::row::row_key(¶.key());
|
||||
let (ptop, pbottom) = (screen.list)(&mut h.rsc)
|
||||
.extent(para_key)
|
||||
.expect("the paragraph row is on screen");
|
||||
h.touch(
|
||||
TouchAction::Down,
|
||||
Vec2::new(540.0, (ptop + pbottom) / 2.0),
|
||||
400,
|
||||
);
|
||||
h.touch(
|
||||
TouchAction::Up,
|
||||
Vec2::new(540.0, (ptop + pbottom) / 2.0),
|
||||
450,
|
||||
);
|
||||
|
||||
let after = row_top(&mut h, &screen, tracked);
|
||||
assert!(
|
||||
(after - before).abs() < 0.5,
|
||||
"a tap after panning a code fence moved the transcript by {}px",
|
||||
after - before
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::{Harness, TouchScript};
|
||||
|
||||
struct CaptureLogger {
|
||||
lines: Mutex<Vec<(log::Level, String)>>,
|
||||
}
|
||||
|
||||
static LOGGER: OnceLock<CaptureLogger> = OnceLock::new();
|
||||
|
||||
impl log::Log for CaptureLogger {
|
||||
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
fn log(&self, record: &log::Record) {
|
||||
self.lines
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((record.level(), record.args().to_string()));
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn logger() -> &'static CaptureLogger {
|
||||
let logger = LOGGER.get_or_init(|| CaptureLogger {
|
||||
lines: Mutex::new(Vec::new()),
|
||||
});
|
||||
let _ = log::set_logger(logger);
|
||||
log::set_max_level(log::LevelFilter::Debug);
|
||||
logger
|
||||
}
|
||||
|
||||
fn drain(logger: &CaptureLogger) -> Vec<(log::Level, String)> {
|
||||
std::mem::take(&mut *logger.lines.lock().unwrap())
|
||||
}
|
||||
|
||||
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
(h, opened.screen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracing_is_silent_off_and_round_trips_the_flick_on() {
|
||||
let logger = logger();
|
||||
|
||||
iris::diagnostics::set_trace(false);
|
||||
drain(logger); // whatever `opened()` itself logged while building
|
||||
let (mut h, screen) = opened();
|
||||
drain(logger); // and whatever opening logged
|
||||
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||
h.replay(&flick);
|
||||
let _ = (screen.list)(&mut h.rsc); // touch the screen the same way a real caller would
|
||||
let quiet = drain(logger);
|
||||
let debug_lines: Vec<_> = quiet
|
||||
.iter()
|
||||
.filter(|(level, _)| *level == log::Level::Debug)
|
||||
.collect();
|
||||
assert!(
|
||||
debug_lines.is_empty(),
|
||||
"tracing is off, but the ring would still have held these `debug!` lines: {debug_lines:#?}"
|
||||
);
|
||||
|
||||
iris::diagnostics::set_trace(true);
|
||||
let (mut h, screen) = opened();
|
||||
drain(logger);
|
||||
h.replay(&flick);
|
||||
let _ = (screen.list)(&mut h.rsc);
|
||||
let traced = drain(logger);
|
||||
iris::diagnostics::set_trace(false); // leave it off for any test after this one
|
||||
|
||||
let input_lines: Vec<&str> = traced
|
||||
.iter()
|
||||
.filter(|(_, msg)| msg.contains("iris input: action="))
|
||||
.map(|(_, msg)| msg.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
input_lines.len(),
|
||||
flick.samples.len(),
|
||||
"expected one `iris::input` line per replayed sample, got:\n{input_lines:#?}"
|
||||
);
|
||||
let frame_lines: Vec<&str> = traced
|
||||
.iter()
|
||||
.filter(|(_, msg)| msg.starts_with("iris frame:"))
|
||||
.map(|(_, msg)| msg.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
!frame_lines.is_empty(),
|
||||
"expected at least one `iris::frame` line once tracing was on"
|
||||
);
|
||||
for line in &frame_lines {
|
||||
assert!(
|
||||
!line.contains("layout=0ns"),
|
||||
"a frame that redrew should not report zero layout time: {line}"
|
||||
);
|
||||
}
|
||||
|
||||
let report = input_lines.join("\n");
|
||||
let script_path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../iris/benches/report_to_touch.py"
|
||||
);
|
||||
let mut child = Command::new("python3")
|
||||
.arg(script_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("python3 must be on PATH to run report_to_touch.py");
|
||||
{
|
||||
use std::io::Write;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(report.as_bytes())
|
||||
.unwrap();
|
||||
}
|
||||
let output = child.wait_with_output().expect("report_to_touch.py exited");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"report_to_touch.py failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let touch_text = String::from_utf8(output.stdout).expect("report_to_touch.py wrote UTF-8");
|
||||
let round_tripped =
|
||||
TouchScript::parse(&touch_text).unwrap_or_else(|e| panic!("round-tripped script: {e}"));
|
||||
|
||||
assert_eq!(
|
||||
round_tripped.samples.len(),
|
||||
flick.samples.len(),
|
||||
"round trip produced a different number of samples:\n{touch_text}"
|
||||
);
|
||||
for (original, back) in flick.samples.iter().zip(round_tripped.samples.iter()) {
|
||||
assert_eq!(original.t_ms, back.t_ms);
|
||||
assert_eq!(original.action, back.action);
|
||||
assert_eq!(original.pos, back.pos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::{Harness, TouchScript};
|
||||
use iris::prelude::*;
|
||||
|
||||
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
(h, opened.screen)
|
||||
}
|
||||
|
||||
fn script(name: &str, text: &str) -> TouchScript {
|
||||
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||
}
|
||||
|
||||
fn offset(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> String {
|
||||
(screen.list)(&mut h.rsc).anchor_position_display()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
||||
let (mut h, screen) = opened();
|
||||
let before = offset(&mut h, &screen);
|
||||
|
||||
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
|
||||
h.replay(&flick);
|
||||
|
||||
let velocity = (screen.list)(&mut h.rsc)
|
||||
.fling_velocity()
|
||||
.expect("the flick must release as a pan with a velocity, not a tap");
|
||||
assert!(
|
||||
(velocity - 15_250.0).abs() < 20.0,
|
||||
"expected ~15250px/s from velocity_reference.py, got {velocity}"
|
||||
);
|
||||
|
||||
const REFERENCE_MS: u64 = 2071;
|
||||
const REFERENCE_PX: f32 = 11057.0;
|
||||
let end = flick.end_ms() + REFERENCE_MS * 2;
|
||||
let mut settled_at = None;
|
||||
let mut t = flick.end_ms();
|
||||
let middle = phone_size().y / 2.0;
|
||||
let mut travelled = 0.0f32;
|
||||
let mut tracked: Option<(RowKey, f32)> = None;
|
||||
while t <= end {
|
||||
h.frame(t);
|
||||
let list = (screen.list)(&mut h.rsc);
|
||||
tracked =
|
||||
match tracked.and_then(|(key, was)| list.extent(key).map(|(now, _)| (key, was, now))) {
|
||||
Some((key, was, now)) => {
|
||||
travelled += (now - was).abs();
|
||||
Some((key, now))
|
||||
}
|
||||
None => list
|
||||
.key_at(middle)
|
||||
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
|
||||
};
|
||||
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
|
||||
settled_at = Some(t);
|
||||
}
|
||||
t += PHONE_FRAME_MS;
|
||||
}
|
||||
|
||||
let after = offset(&mut h, &screen);
|
||||
assert_ne!(
|
||||
before, after,
|
||||
"the fling ticks must have moved the list off where the flick left it"
|
||||
);
|
||||
let settled_at = settled_at.expect("the fling must stop on its own, not run forever");
|
||||
let ran_for = settled_at - flick.end_ms();
|
||||
assert!(
|
||||
ran_for >= REFERENCE_MS - PHONE_FRAME_MS * 2,
|
||||
"the fling stopped after {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
|
||||
);
|
||||
assert!(
|
||||
ran_for <= REFERENCE_MS + PHONE_FRAME_MS * 2,
|
||||
"the fling ran {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
|
||||
);
|
||||
assert!(
|
||||
travelled >= REFERENCE_PX * 0.8,
|
||||
"the fling travelled {travelled:.0}px against the spline reference's {REFERENCE_PX:.0}px"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tap_on_a_row_moves_nothing() {
|
||||
let (mut h, screen) = opened();
|
||||
let before = offset(&mut h, &screen);
|
||||
|
||||
h.replay(&script("tap", include_str!("../touch/tap.touch")));
|
||||
|
||||
assert_eq!(
|
||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||
None,
|
||||
"a tap must not fling"
|
||||
);
|
||||
h.frames_until(100, 400, PHONE_FRAME_MS);
|
||||
assert_eq!(before, offset(&mut h, &screen), "a tap must scroll nothing");
|
||||
assert_eq!(
|
||||
h.state.opened_urls,
|
||||
Vec::<String>::new(),
|
||||
"no link was under this tap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_press_and_drag_selects_text() {
|
||||
let (mut h, screen) = opened();
|
||||
let before = offset(&mut h, &screen);
|
||||
|
||||
h.replay(&script(
|
||||
"long-press",
|
||||
include_str!("../touch/long-press.touch"),
|
||||
));
|
||||
|
||||
let selected = screen
|
||||
.selected_text(&mut h.rsc)
|
||||
.expect("a long-press then drag must leave text selected");
|
||||
assert!(
|
||||
!selected.trim().is_empty(),
|
||||
"the selection covered no characters: {selected:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
before,
|
||||
offset(&mut h, &screen),
|
||||
"a selection must not also pan the list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tap_after_selection_deselects_text() {
|
||||
let (mut h, screen) = opened();
|
||||
h.replay(&script(
|
||||
"select-then-tap",
|
||||
"0 down 300 1000\n\
|
||||
520 move 300 1000\n\
|
||||
560 move 700 1000\n\
|
||||
600 move 900 1000\n\
|
||||
640 up 900 1000\n\
|
||||
800 down 540 1000\n\
|
||||
880 up 540 1000",
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
screen.selected_text(&mut h.rsc),
|
||||
None,
|
||||
"an ordinary tap after a selection must dismiss it"
|
||||
);
|
||||
assert_eq!(
|
||||
h.state.opened_urls,
|
||||
Vec::<String>::new(),
|
||||
"the deselecting tap must be consumed rather than activating content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_composer_sits_above_a_simulated_ime_inset() {
|
||||
let (mut h, screen) = opened();
|
||||
let height = h.size().y;
|
||||
let field_bottom = |h: &mut Harness| {
|
||||
h.render
|
||||
.window_region(&screen.composer.field, &h.rsc)
|
||||
.expect("the composer field is on screen")
|
||||
.bot_right
|
||||
.y
|
||||
};
|
||||
|
||||
let closed = field_bottom(&mut h);
|
||||
assert!(
|
||||
closed <= height,
|
||||
"the composer is off the bottom of the window even with no keyboard: {closed} > {height}"
|
||||
);
|
||||
|
||||
let ime = 1000.0;
|
||||
screen.composer.set_bottom_inset(&mut h.rsc, ime);
|
||||
h.frame(PHONE_FRAME_MS * 2);
|
||||
|
||||
let open = field_bottom(&mut h);
|
||||
assert!(
|
||||
open <= height - ime,
|
||||
"the keyboard covers the composer: its bottom is at {open}, the IME starts at {}",
|
||||
height - ime
|
||||
);
|
||||
assert!(
|
||||
(closed - open - ime).abs() < 1.0,
|
||||
"the composer moved {} for a {ime}px inset",
|
||||
closed - open
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_space_in_the_composer_finishes_layout() {
|
||||
let (mut h, screen) = opened();
|
||||
screen.composer.set_bottom_inset(&mut h.rsc, 1000.0);
|
||||
h.frame(PHONE_FRAME_MS * 2);
|
||||
h.state.set_focus(Some(screen.composer.field));
|
||||
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
|
||||
|
||||
for text in ["h", "i", " "] {
|
||||
screen.composer.field.edit(&mut h.rsc).insert(text);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
}
|
||||
|
||||
assert_eq!(screen.composer.field.edit(&mut h.rsc).text.text(), "hi ");
|
||||
let region = h
|
||||
.render
|
||||
.window_region(&screen.composer.field, &h.rsc)
|
||||
.expect("the composer field is drawn");
|
||||
let size = region.bot_right - region.top_left;
|
||||
assert!(
|
||||
size.x > phone_size().x / 2.0,
|
||||
"the field shrink-wrapped to the short message: {region:?}"
|
||||
);
|
||||
assert!(
|
||||
size.y < 30.0 * PHONE_SCALE,
|
||||
"the trailing space wrapped onto a second line: {region:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newline_leaves_the_caret_inside_the_composers_padding() {
|
||||
let (mut h, screen) = opened();
|
||||
let height = h.size().y;
|
||||
let ime = 1000.0;
|
||||
screen.composer.set_bottom_inset(&mut h.rsc, ime);
|
||||
h.frame(PHONE_FRAME_MS * 2);
|
||||
|
||||
h.state.set_focus(Some(screen.composer.field));
|
||||
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
|
||||
for _ in 0..12 {
|
||||
screen.composer.field.edit(&mut h.rsc).insert("a\n");
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
}
|
||||
let message = h
|
||||
.render
|
||||
.debug(h.rsc.widgets(), "Message")
|
||||
.find(|a| !a.primitives.is_empty())
|
||||
.expect("the composer field is drawn");
|
||||
let caret = h
|
||||
.render
|
||||
.primitive_corners(message.primitives.last().unwrap().slot, &h.rsc);
|
||||
let bar_bottom = height - ime;
|
||||
let padding = 12.0 * PHONE_SCALE;
|
||||
assert!(
|
||||
caret.bot_right.y < bar_bottom - padding / 2.0,
|
||||
"the caret is in the bar's bottom padding: it ends at {}, the bar's edge is {bar_bottom} \
|
||||
and its padding is {padding}px",
|
||||
caret.bot_right.y,
|
||||
);
|
||||
|
||||
let mask = h.rsc.ui.masks[message.mask.idx()];
|
||||
let bar = h.render.primitive_corners(mask.primitive, &h.rsc);
|
||||
let visible_content_top = message
|
||||
.primitives
|
||||
.iter()
|
||||
.map(|p| h.render.primitive_corners(p.slot, &h.rsc))
|
||||
.filter(|r| r.bot_right.y > bar.top_left.y)
|
||||
.map(|r| r.top_left.y)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
assert!(
|
||||
visible_content_top > bar.top_left.y,
|
||||
"the composer's first visible line is clipped above its bar: content starts at {visible_content_top}, bar starts at {}",
|
||||
bar.top_left.y,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::Harness;
|
||||
use iris::prelude::*;
|
||||
|
||||
const HEADER_H: f32 = 300.0;
|
||||
const HEADER: Srgba8 = Srgba8::new(28, 28, 34, 255);
|
||||
|
||||
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let (opened, tree) = ai_app::ui::fixture::build_screen(&mut h.rsc).expect("the fixture folds");
|
||||
let content = WidgetPtr::new().add(&mut h.rsc);
|
||||
content(&mut h.rsc).set(tree);
|
||||
let root = (rect(HEADER).height(abs(HEADER_H)), content.height(rest(1)))
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(&mut h.rsc)
|
||||
.any();
|
||||
h.state.set_root(&mut h.rsc, root);
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
(h, opened.screen)
|
||||
}
|
||||
|
||||
fn list_box(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> PixelRegion {
|
||||
h.render
|
||||
.window_region(&screen.list.id(), &h.rsc)
|
||||
.expect("the list is on screen")
|
||||
}
|
||||
|
||||
fn drawn_rows(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> Vec<(f32, f32)> {
|
||||
let mut rows: Vec<(f32, f32)> = h
|
||||
.render
|
||||
.active
|
||||
.get(&screen.list.id())
|
||||
.expect("the list is drawn")
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|id| h.render.window_region(id, &h.rsc))
|
||||
.map(|px| (px.top_left.y, px.bot_right.y))
|
||||
.collect();
|
||||
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
rows
|
||||
}
|
||||
|
||||
fn scrolled(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
|
||||
(screen.list)(&mut h.rsc).scroll(amount);
|
||||
h.frame(t);
|
||||
t + PHONE_FRAME_MS
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_row_across_the_top_edge_is_drawn() {
|
||||
let (mut h, screen) = opened();
|
||||
let top = list_box(&h, &screen).top_left.y;
|
||||
let mut t = PHONE_FRAME_MS * 2;
|
||||
|
||||
for _ in 0..60 {
|
||||
t = scrolled(&mut h, &screen, 40.0, t);
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
let first = *rows.first().expect("something is on screen");
|
||||
assert!(
|
||||
first.0 <= top + 0.5,
|
||||
"a band of {:.1}px under the header belongs to no row: rows start at {:.1}, the list \
|
||||
at {top:.1}",
|
||||
first.0 - top,
|
||||
first.0,
|
||||
);
|
||||
assert!(
|
||||
first.1 > top,
|
||||
"the row across the top edge was culled: it ends at {:.1}, above the list's own \
|
||||
{top:.1}",
|
||||
first.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_is_clipped_to_its_own_box() {
|
||||
let (h, screen) = opened();
|
||||
let active = h.render.active.get(&screen.list.id()).expect("drawn");
|
||||
assert!(
|
||||
active.mask != MaskIdx::NONE,
|
||||
"the transcript's list is drawn with nothing clipping it",
|
||||
);
|
||||
let clip = h.render.mask_region(active.mask, &h.rsc);
|
||||
let list = list_box(&h, &screen);
|
||||
assert!(
|
||||
clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5,
|
||||
"the clip {clip:?} reaches outside the list's own box {list:?}, so a row straddling an \
|
||||
edge still draws past it",
|
||||
);
|
||||
|
||||
let rows = h
|
||||
.render
|
||||
.active
|
||||
.get(&screen.list.id())
|
||||
.expect("the list is drawn")
|
||||
.children
|
||||
.clone();
|
||||
let mut checked = 0;
|
||||
for row in rows {
|
||||
for prim in primitives_under(&h, row) {
|
||||
assert!(
|
||||
mask_chain(&h, prim).contains(&active.mask),
|
||||
"a primitive of row {row:?} clips to {:?}, a chain that never reaches the list's \
|
||||
own mask {:?}",
|
||||
mask_chain(&h, prim),
|
||||
active.mask,
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
checked > 0,
|
||||
"no row primitive was checked, so this test asserted nothing",
|
||||
);
|
||||
}
|
||||
|
||||
fn primitives_under(h: &Harness, id: WidgetId) -> Vec<MaskIdx> {
|
||||
let Some(active) = h.render.active.get(&id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<MaskIdx> = active
|
||||
.primitives
|
||||
.iter()
|
||||
.filter(|p| p.binding != IMAGE_BINDING)
|
||||
.map(|p| h.render.primitives.instance(p.slot).mask_idx)
|
||||
.collect();
|
||||
for child in &active.children {
|
||||
out.extend(primitives_under(h, *child));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
|
||||
let mut chain = Vec::new();
|
||||
let mut at = mask;
|
||||
while at != MaskIdx::NONE {
|
||||
assert!(
|
||||
!chain.contains(&at),
|
||||
"the mask chain from {mask:?} loops back to {at:?}",
|
||||
);
|
||||
chain.push(at);
|
||||
at = h.rsc.ui.masks[at.idx()].parent;
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_that_have_left_the_viewport_are_not_drawn() {
|
||||
let (mut h, screen) = opened();
|
||||
let list = list_box(&h, &screen);
|
||||
let mut t = PHONE_FRAME_MS * 2;
|
||||
let bounded = |rows: &[(f32, f32)], leg: &str, step: usize| {
|
||||
assert!(
|
||||
rows.len() <= 24,
|
||||
"{leg} {step}: {} rows drawn for one 2012px viewport",
|
||||
rows.len(),
|
||||
);
|
||||
};
|
||||
let inside = |rows: &[(f32, f32)], leg: &str, step: usize| {
|
||||
for &(top, bottom) in rows {
|
||||
assert!(
|
||||
bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5,
|
||||
"{leg} {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \
|
||||
{list:?} and was drawn anyway",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
for step in 0..40 {
|
||||
t = scrolled(&mut h, &screen, 400.0, t);
|
||||
bounded(&drawn_rows(&h, &screen), "measuring", step);
|
||||
}
|
||||
for step in 0..40 {
|
||||
t = scrolled(&mut h, &screen, -400.0, t);
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
bounded(&rows, "forward", step);
|
||||
inside(&rows, "forward", step);
|
||||
}
|
||||
for step in 0..40 {
|
||||
t = scrolled(&mut h, &screen, 400.0, t);
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
bounded(&rows, "back", step);
|
||||
inside(&rows, "back", step);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_row_across_the_bottom_edge_is_drawn() {
|
||||
let (mut h, screen) = opened();
|
||||
let list = list_box(&h, &screen);
|
||||
let mut t = PHONE_FRAME_MS * 2;
|
||||
|
||||
for _ in 0..40 {
|
||||
t = scrolled(&mut h, &screen, 37.0, t);
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
let last = *rows.last().expect("something is on screen");
|
||||
assert!(
|
||||
last.1 >= list.bot_right.y - 0.5,
|
||||
"a band of {:.1}px above the composer belongs to no row",
|
||||
list.bot_right.y - last.1,
|
||||
);
|
||||
assert!(
|
||||
last.0 < list.bot_right.y,
|
||||
"the row across the bottom edge was culled: it starts at {:.1}, below the list's own \
|
||||
{:.1}",
|
||||
last.0,
|
||||
list.bot_right.y,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_past_the_first_row_settles_on_it() {
|
||||
let (mut h, screen) = opened();
|
||||
let list = list_box(&h, &screen);
|
||||
let mut t = PHONE_FRAME_MS * 2;
|
||||
|
||||
for _ in 0..60 {
|
||||
t = scrolled(&mut h, &screen, 100_000.0, t);
|
||||
}
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
let first = *rows.first().expect("the first row is on screen");
|
||||
assert!(
|
||||
(first.0 - list.top_left.y).abs() < 0.5,
|
||||
"the transcript is parked {:.1}px past its own first row, so the top of the list is blank",
|
||||
first.0 - list.top_left.y,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_past_the_last_row_settles_on_it() {
|
||||
let (mut h, screen) = opened();
|
||||
let list = list_box(&h, &screen);
|
||||
let mut t = PHONE_FRAME_MS * 2;
|
||||
|
||||
for _ in 0..20 {
|
||||
t = scrolled(&mut h, &screen, -100_000.0, t);
|
||||
}
|
||||
|
||||
let rows = drawn_rows(&h, &screen);
|
||||
let last = *rows.last().expect("the last row is on screen");
|
||||
assert!(
|
||||
(last.1 - list.bot_right.y).abs() < 0.5,
|
||||
"the transcript is parked {:.1}px past its own last row, so the bottom of the list is \
|
||||
blank",
|
||||
list.bot_right.y - last.1,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# A finger flick the shape Iris's phone delivers one, from
|
||||
# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's
|
||||
# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app
|
||||
# as DOWN, one or two MOVEs and UP inside a few frames, with the
|
||||
# intermediate positions batched inside those MOVEs as historical
|
||||
# samples (~4ms apart, the touch digitiser's own rate) rather than
|
||||
# arriving as separate events. Each line here is one such sample, which
|
||||
# is exactly what `IrisViewPeer::on_touch_event` replays through the
|
||||
# sensors one at a time -- so the whole gesture is 20ms and five
|
||||
# samples, and the velocity has to come out of *those*.
|
||||
#
|
||||
# Downward (increasing y) on purpose: the screen opens pinned to the
|
||||
# newest end, so a flick the other way has nothing left to scroll to and
|
||||
# the fling clamps on its first tick -- a pass that would prove nothing.
|
||||
# Coordinates are physical pixels on a 1080x2424 surface.
|
||||
0 down 540 1000
|
||||
4 move 540 1040
|
||||
8 move 540 1086
|
||||
12 move 540 1138
|
||||
16 move 540 1196
|
||||
20 up 540 1196
|
||||
@@ -0,0 +1,11 @@
|
||||
# `flick-120hz.touch` to the sample, with the platform taking the gesture
|
||||
# away instead of the finger lifting -- Android's `ACTION_CANCEL`, which
|
||||
# is what the swipe up from the bottom edge to leave the app delivers
|
||||
# after its moves. Nothing may follow from it: no tap, no selection and,
|
||||
# the one that showed on Iris's phone, no fling.
|
||||
0 down 540 1000
|
||||
4 move 540 1040
|
||||
8 move 540 1086
|
||||
12 move 540 1138
|
||||
16 move 540 1196
|
||||
20 cancel 540 1196
|
||||
@@ -0,0 +1,11 @@
|
||||
# A long-press then a drag across the text: held past LONG_PRESS
|
||||
# (500ms) without moving, which is what starts a selection rather than a
|
||||
# pan, then dragged sideways so the selection actually covers
|
||||
# something. A press alone leaves a collapsed caret and no selected
|
||||
# text (`Selection::begin`), which is why this file does not stop at the
|
||||
# hold.
|
||||
0 down 300 1000
|
||||
520 move 300 1000
|
||||
560 move 700 1000
|
||||
600 move 900 1000
|
||||
640 up 900 1000
|
||||
@@ -0,0 +1,5 @@
|
||||
# The case the flick had no reason to touch: a press and release in one
|
||||
# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a
|
||||
# tap -- no pan, no velocity, nothing moved.
|
||||
0 down 540 1000
|
||||
80 up 540 1000
|
||||
@@ -102,6 +102,16 @@ android {
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
// Read by MainActivity to decide, at startup, whether this is the P0 benchmark build
|
||||
// (docs/RUST.md's P0 box) rather than the app somebody enrolled. False everywhere except
|
||||
// the `bench` build type below, which overrides it.
|
||||
buildConfigField("boolean", "FIXTURE_MODE", "false")
|
||||
}
|
||||
buildFeatures {
|
||||
// Only for FIXTURE_MODE above; nothing else here reaches for generated BuildConfig fields.
|
||||
buildConfig = true
|
||||
// Only for the bench build type's resValue("string", "app_name", ...) below.
|
||||
resValues = true
|
||||
}
|
||||
packaging {
|
||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||
@@ -131,6 +141,35 @@ android {
|
||||
isMinifyEnabled = false
|
||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
// P0's benchmark build (docs/RUST.md, docs/DECISIONS.md's 2026-09-05 entry): release
|
||||
// optimisations so a frame time measured here means what release means everywhere else in
|
||||
// this project, its own application id so it installs beside a real enrollment rather than
|
||||
// replacing it, and FIXTURE_MODE so MainActivity opens straight onto the fixture session
|
||||
// instead of asking to be enrolled. Signed with the same key as release -- it never talks
|
||||
// to a real backend, so there is no CA of its own to mismatch, and a second keystore would
|
||||
// be one more secret to keep off this machine's shared mount for no benefit.
|
||||
create("bench") {
|
||||
initWith(getByName("release"))
|
||||
// :link (wg-app-link) has no "bench" build type of its own -- it is a library shared
|
||||
// with dev-updater and has no reason to know this project invented one -- so this says
|
||||
// which of its build types to link against instead.
|
||||
matchingFallbacks += listOf("release")
|
||||
applicationIdSuffix = ".bench"
|
||||
// "AI Sessions bench" everywhere the OS shows the app's name (launcher, recents,
|
||||
// Settings): this resValue overrides res/values/strings.xml's app_name for this
|
||||
// build type alone, and AndroidManifest.xml's android:label reads @string/app_name
|
||||
// rather than a literal so a build type can override it without touching the
|
||||
// manifest.
|
||||
resValue("string", "app_name", "AI Sessions bench")
|
||||
buildConfigField("boolean", "FIXTURE_MODE", "true")
|
||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
// The fixture both bench builds (this one and iris's) open with; see
|
||||
// app/bench-fixture/README.md. Read directly from its own directory rather than copied
|
||||
// into androidApp/src -- one file to keep in sync with the generator, not two.
|
||||
getByName("bench").assets.directories.add("../bench-fixture/assets")
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
the fix is a judgement about how this app should look. Drop this
|
||||
suppression when a real icon lands. -->
|
||||
<application
|
||||
android:label="AI Sessions"
|
||||
android:label="@string/app_name"
|
||||
android:allowBackup="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
tools:ignore="MissingApplicationIcon">
|
||||
|
||||
@@ -280,7 +280,7 @@ fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
||||
* 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
|
||||
* this carries only what a card needs to draw and to open it; see docs/SUBAGENTS.md. [status] is
|
||||
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
|
||||
* running, and the list says so rather than reporting a state that cannot hold.
|
||||
*/
|
||||
|
||||
@@ -49,7 +49,7 @@ private sealed class 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
|
||||
* docs/SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
|
||||
* is opened from the session list's card rather than from inside the session it belongs to.
|
||||
*/
|
||||
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
|
||||
|
||||
@@ -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 the 2026-09-05 decision): an in-process fake of the
|
||||
* backend, so the `bench` build type can drive a real session screen -- the real
|
||||
* [TranscriptSource], the real fold, the real paging -- with no server and no network permission.
|
||||
*
|
||||
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
|
||||
* in this build compiles it in but never calls it, since Kotlin has no per-build-type source set
|
||||
* that both [MainActivity] (which every variant compiles) and this can share without one.
|
||||
*
|
||||
* The design: [requestFromServer] and [Sse] talk to `https://$FIXTURE_HOST:$FIXTURE_PORT` through
|
||||
* ordinary `java.net.URL`, exactly as they would talk to a real server. A
|
||||
* [java.net.URLStreamHandlerFactory] registered once for the whole process intercepts every
|
||||
* `https://` connection to that host and answers from this object's in-memory event log instead of
|
||||
* opening a socket -- see BenchNetwork.kt. Everything above that (TranscriptSource, SessionScreen,
|
||||
* the fold, uniqueItems) never learns the difference.
|
||||
*/
|
||||
object BenchFixture {
|
||||
const val FIXTURE_HOST = "bench.fixture.invalid"
|
||||
const val FIXTURE_PORT = 1
|
||||
|
||||
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
|
||||
private const val BACKLOG_COUNT = 3202
|
||||
|
||||
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
|
||||
|
||||
/** The session id every bench run opens; nothing else in this build ever mints one. */
|
||||
const val SESSION_ID = "bench-fixture-session"
|
||||
|
||||
/**
|
||||
* The whole transcript, seq order, growing as [pushLive] is called during the streaming phase.
|
||||
* Read by both the REST page handler and the SSE handler, so a page requested mid- stream and a
|
||||
* live frame agree on what has "already happened" -- the same thing a real server's own
|
||||
* transcript file guarantees.
|
||||
*/
|
||||
private val log = CopyOnWriteArrayList<Pair<String, SeqEvent>>()
|
||||
|
||||
/** The events not yet appended to [log] -- the streaming phase's own source. */
|
||||
private var streamTail: List<Pair<String, SeqEvent>> = emptyList()
|
||||
|
||||
private val images = mutableMapOf<String, ByteArray>()
|
||||
|
||||
@Volatile private var loaded = false
|
||||
|
||||
/**
|
||||
* Parses the bundled fixture once. Safe to call more than once; only the first does anything.
|
||||
*/
|
||||
@Synchronized
|
||||
fun ensureLoaded(context: Context) {
|
||||
if (loaded) return
|
||||
val lines =
|
||||
context.assets.open("transcript.jsonl").bufferedReader().readLines().filter {
|
||||
it.isNotBlank()
|
||||
}
|
||||
val parsed = lines.map { it to parseSeqEvent(it) }
|
||||
log.addAll(parsed.take(BACKLOG_COUNT))
|
||||
streamTail = parsed.drop(BACKLOG_COUNT)
|
||||
for (name in listOf("bench1.png", "bench2.png")) {
|
||||
images[name] = context.assets.open(name).readBytes()
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
|
||||
/** The events the streaming phase has left to send. */
|
||||
fun remainingStreamEvents(): Int = streamTail.size
|
||||
|
||||
/** Sends the next fixture event onto the live log, as a real SSE frame would arrive. */
|
||||
fun pushNextLiveEvent(): Boolean {
|
||||
val next = streamTail.firstOrNull() ?: return false
|
||||
streamTail = streamTail.drop(1)
|
||||
log.add(next)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Undoes [pushNextLiveEvent] and reloads the opening backlog, for running the bench twice. */
|
||||
@Synchronized
|
||||
fun resetToBacklog(context: Context) {
|
||||
loaded = false
|
||||
log.clear()
|
||||
ensureLoaded(context)
|
||||
}
|
||||
|
||||
fun fileBytes(name: String): ByteArray? = images[name]
|
||||
|
||||
/**
|
||||
* Raw JSON lines with seq > [after], in order -- what an `/events?after=` connection replays.
|
||||
*/
|
||||
fun linesAfter(after: Long): List<String> =
|
||||
log.filter { it.second.seq > after }.map { it.first }
|
||||
|
||||
/**
|
||||
* One REST page: [fetchTranscript]'s `before`/`limit`/`after`, against the growing log. Ignores
|
||||
* `coalesce` -- the fixture's own deltas are already split the way a real reply streams, and
|
||||
* what the benchmark exercises is the fold and the paging, not the server's row-joining, which
|
||||
* client-core's own port tracks separately (CLIENT_CORE.md).
|
||||
*/
|
||||
fun page(before: Long?, limit: Int, after: Long?): List<String> {
|
||||
val upper = before ?: (log.lastOrNull()?.second?.seq?.plus(1) ?: 1L)
|
||||
val candidates = log.filter {
|
||||
it.second.seq < upper && (after == null || it.second.seq > after)
|
||||
}
|
||||
return candidates.takeLast(limit).map { it.first }
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
accounting: List<String>,
|
||||
crash: String?,
|
||||
/**
|
||||
* P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on
|
||||
* every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section
|
||||
* heading only appears when there is something to put under it, so an ordinary copy from the
|
||||
* render-report button reads exactly as it did before this existed.
|
||||
*/
|
||||
extra: List<String> = emptyList(),
|
||||
/**
|
||||
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
|
||||
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
|
||||
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
|
||||
* [extra].
|
||||
*/
|
||||
phaseFrames: List<String> = emptyList(),
|
||||
): String = buildString {
|
||||
appendLine("ai-app render report")
|
||||
appendLine(device)
|
||||
@@ -141,6 +155,11 @@ fun debugReport(
|
||||
appendLine("transcript:")
|
||||
transcript.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
if (phaseFrames.isNotEmpty()) {
|
||||
appendLine("per phase:")
|
||||
phaseFrames.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
}
|
||||
appendLine("frames:")
|
||||
frames.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
@@ -152,6 +171,11 @@ fun debugReport(
|
||||
appendLine("work since this was last copied:")
|
||||
val work = DebugStats.lines()
|
||||
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
|
||||
if (extra.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("bench:")
|
||||
extra.forEach { appendLine(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
|
||||
|
||||
@@ -42,6 +42,21 @@ object FrameStats {
|
||||
private val gpu = ArrayList<Long>()
|
||||
private var since = System.currentTimeMillis()
|
||||
|
||||
/**
|
||||
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
|
||||
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
|
||||
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
|
||||
*/
|
||||
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
|
||||
|
||||
private val phaseMarks = ArrayList<PhaseMark>()
|
||||
|
||||
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
|
||||
@Synchronized
|
||||
fun markPhase(name: String) {
|
||||
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun add(metrics: FrameMetrics) {
|
||||
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
||||
@@ -69,6 +84,7 @@ object FrameStats {
|
||||
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
||||
it.clear()
|
||||
}
|
||||
phaseMarks.clear()
|
||||
since = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@@ -95,6 +111,38 @@ object FrameStats {
|
||||
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
||||
}
|
||||
|
||||
/**
|
||||
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
|
||||
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
|
||||
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
|
||||
* that finished before a frame was drawn) still get a line rather than being silently dropped
|
||||
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
|
||||
*/
|
||||
@Synchronized
|
||||
fun phaseLines(refreshHz: Float): List<String> {
|
||||
if (phaseMarks.isEmpty()) return emptyList()
|
||||
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
|
||||
val lines = ArrayList<String>()
|
||||
phaseMarks.forEachIndexed { i, mark ->
|
||||
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
|
||||
val endMs =
|
||||
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
|
||||
else System.currentTimeMillis()
|
||||
val samples = total.subList(mark.startIndex, endIndex)
|
||||
val seconds = (endMs - mark.startMs) / 1000.0
|
||||
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
|
||||
if (samples.isEmpty()) {
|
||||
lines += " no frames recorded in this phase"
|
||||
} else {
|
||||
val late = samples.count { it / 1_000_000.0 > budget }
|
||||
lines += " late: $late (${percent(late, samples.size)})"
|
||||
lines += " " + phase("total ", samples)
|
||||
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** How long the frames recorded here spent in their draw phase, and how many there were. */
|
||||
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
||||
|
||||
|
||||
@@ -66,6 +66,35 @@ class MainActivity : ComponentActivity() {
|
||||
// Transparent status bar on every version; the Surface below paints through underneath it
|
||||
// and content insets itself. Same reasoning as dev-updater's MainActivity.
|
||||
enableEdgeToEdge()
|
||||
|
||||
// The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session
|
||||
// screen against BenchFixture's in-process fake backend, with no enrollment, no network
|
||||
// permission, and no notification prompt -- none of them mean anything with no server and
|
||||
// no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built
|
||||
// to talk to a real backend is made to talk to this instead. Still needs the same
|
||||
// status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the
|
||||
// platform's own default from Android 15 on this app's targetSdk, with or without the call
|
||||
// above, so skipping the padding here put the header's own buttons under the status bar --
|
||||
// there to look at, but not there for `ui-trace`'s tap-by-label to land on.
|
||||
if (BuildConfig.FIXTURE_MODE) {
|
||||
installFixtureNetworkOnce()
|
||||
BenchFixture.ensureLoaded(this)
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = AiAppColors) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
|
||||
SessionScreen(
|
||||
settings = BenchFixture.settings,
|
||||
summary = benchSessionSummary(),
|
||||
onBack = { finish() },
|
||||
onFiles = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Dark status-bar icons only over a light background, decided from the scheme rather than
|
||||
// fixed. It was hardcoded to `true`, which was right against the default light surface and
|
||||
// became unreadable the moment the app wore Catppuccin Mocha.
|
||||
@@ -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,
|
||||
// lands here rather than in a second activity instance.
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
||||
@@ -478,7 +478,7 @@ private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
|
||||
* The subcard's word for a subagent's status -- see docs/SUBAGENTS.md's "Wire shape". Its own function
|
||||
* rather than a branch inside [StatusText], because a subagent's three states are not that
|
||||
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
|
||||
* and never something of its own to have merely stopped.
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -64,12 +65,15 @@ import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextRange
|
||||
@@ -291,6 +295,12 @@ fun SessionScreen(
|
||||
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
|
||||
// note opened and scrolled past is still open on the way back.
|
||||
var openMemories by remember { mutableStateOf(setOf<String>()) }
|
||||
// Which capped blocks the reader has asked to see whole: a tool call's input or output
|
||||
// ([Capped]), and long messages, by the row key that identifies them. Held here rather than in
|
||||
// the card or the row for `openMemories`' reason -- something opened and scrolled past is still
|
||||
// open on the way back, and a lazy list drops the composition of anything off screen.
|
||||
var shownWholeCalls by remember { mutableStateOf(setOf<Capped>()) }
|
||||
var shownWholeRows by remember { mutableStateOf(setOf<Any>()) }
|
||||
// The image being looked at full screen, by ref. Here rather than in the row that drew the
|
||||
// thumbnail: a row regrouped underneath the reader takes its whole subtree with it.
|
||||
var fullImage by remember { mutableStateOf<String?>(null) }
|
||||
@@ -354,6 +364,17 @@ fun SessionScreen(
|
||||
// `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
|
||||
// fight over the first frame.
|
||||
val listState = remember(address) { LazyListState() }
|
||||
// The list's own fling path -- what a real flick decays through -- captured here so BenchRun's
|
||||
// fling phase can drive `LazyListState.scroll` through exactly the `FlingBehavior` this
|
||||
// screen's
|
||||
// `TranscriptList` already uses by not overriding it (its `LazyColumn` takes no `flingBehavior`
|
||||
// argument, so this is the same default it gets).
|
||||
val flingBehavior = ScrollableDefaults.flingBehavior()
|
||||
// Where BenchRun's type phase focuses before it types, and the view it toggles the keyboard on
|
||||
// -- both bench-only, but cheap enough (a remembered object, a CompositionLocal read) to hold
|
||||
// unconditionally rather than behind a second code path only the bench build compiles.
|
||||
val composerFocus = remember { FocusRequester() }
|
||||
val view = LocalView.current
|
||||
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
|
||||
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
|
||||
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
|
||||
@@ -367,12 +388,14 @@ fun SessionScreen(
|
||||
// Bumped when a cold reply's parses become ready, so the flatten runs again and can split it.
|
||||
var warmedTick by remember { mutableIntStateOf(0) }
|
||||
val units =
|
||||
remember(rows, expandedNotes, warmedTick) { transcriptUnits(rows, replies, expandedNotes) }
|
||||
remember(rows, expandedNotes, warmedTick, shownWholeRows) {
|
||||
transcriptUnits(rows, replies, expandedNotes, shownWholeRows)
|
||||
}
|
||||
// The reply that just finished streaming is the one row whose parses nobody has made: pages
|
||||
// warm before their fold lands, but nothing warms live deltas. Off the composing thread, then
|
||||
// the tick re-flattens, so settling never costs a whole-message parse in a frame.
|
||||
LaunchedEffect(rows) {
|
||||
val cold = unwarmedReplies(rows, replies)
|
||||
LaunchedEffect(rows, shownWholeRows) {
|
||||
val cold = unwarmedReplies(rows, replies, shownWholeRows)
|
||||
if (cold.isNotEmpty()) {
|
||||
warm(replies, cold)
|
||||
warmedTick++
|
||||
@@ -535,6 +558,20 @@ fun SessionScreen(
|
||||
toggle()
|
||||
}
|
||||
|
||||
/**
|
||||
* [toggleAnchored] for a control that sits *below* the row it grows -- a capped message's "Show
|
||||
* all", which is its own list item under the message it reveals.
|
||||
*
|
||||
* Always the top edge, with no reading of which half was touched: the control is at the bottom
|
||||
* of the row by construction, and the whole point of pressing it is that the text just above it
|
||||
* continues. Held from the bottom instead, the revealed lines would push everything the reader
|
||||
* had been reading up off the screen and leave them at the end of the message.
|
||||
*/
|
||||
fun expandAnchored(key: Any, reveal: () -> Unit) = expanding {
|
||||
topEdgeHeld.key = key
|
||||
reveal()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the row holding transcript position [seq] is loaded, with older history behind it.
|
||||
*
|
||||
@@ -1228,7 +1265,12 @@ fun SessionScreen(
|
||||
// the bench scripts keep working when this moves again. They pressed it at a hand-measured
|
||||
// coordinate until 2026-09-03, and anything that moved the header made that tap land on
|
||||
// whatever now sat there -- reporting a number that was never measured.
|
||||
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 =
|
||||
debugReport(
|
||||
device =
|
||||
@@ -1250,6 +1292,11 @@ fun SessionScreen(
|
||||
accounting =
|
||||
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
||||
crash = lastCrash(context),
|
||||
extra = extra,
|
||||
// Empty outside a BenchRun.run pass -- copyRenderReport's own reset below clears
|
||||
// the
|
||||
// marks along with everything else, so an ordinary copy never has any to show.
|
||||
phaseFrames = FrameStats.phaseLines(context.refreshHz()),
|
||||
)
|
||||
context.copyToClipboard("ai-app render report", report)
|
||||
// Also to the log, so a session driving the app over adb can read the same report the
|
||||
@@ -1263,6 +1310,29 @@ fun SessionScreen(
|
||||
DebugStats.reset()
|
||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val copyRenderReport = { buildAndCopyReport() }
|
||||
// Bench build only: P0's scripted fling/stream/type/keyboard benchmark (BenchRun.kt), against
|
||||
// the fixture session opened below instead of a real server. Null everywhere else -- see
|
||||
// [SessionSettingsDialog]'s onRunBenchmark.
|
||||
val runBenchmark: (() -> Unit)? =
|
||||
if (BuildConfig.FIXTURE_MODE) {
|
||||
{
|
||||
settingsOpen = false
|
||||
scope.launch {
|
||||
val extra =
|
||||
BenchRun.run(
|
||||
context = context,
|
||||
scope = scope,
|
||||
listState = listState,
|
||||
flingBehavior = flingBehavior,
|
||||
composerFocus = composerFocus,
|
||||
setComposerText = { text -> input = atEnd(text) },
|
||||
view = view,
|
||||
)
|
||||
buildAndCopyReport(extra)
|
||||
}
|
||||
}
|
||||
} else null
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
@@ -1438,6 +1508,15 @@ fun SessionScreen(
|
||||
) { unit ->
|
||||
when (unit) {
|
||||
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
|
||||
is TranscriptUnit.ShowAll ->
|
||||
ShowAllRow(unit.lines) {
|
||||
// Anchored like every other control that changes a row's
|
||||
// height: the reader is looking at the row this belongs to, and
|
||||
// it is about to get much taller.
|
||||
expandAnchored(unit.row) {
|
||||
shownWholeRows = shownWholeRows + unit.row
|
||||
}
|
||||
}
|
||||
is TranscriptUnit.PeerHead ->
|
||||
PeerHeadRow(
|
||||
unit.item,
|
||||
@@ -1528,6 +1607,12 @@ fun SessionScreen(
|
||||
::openImage,
|
||||
)
|
||||
},
|
||||
isWhole = { it in shownWholeCalls },
|
||||
onShowAll = { capped ->
|
||||
toggleAnchored(row) {
|
||||
shownWholeCalls = shownWholeCalls + capped
|
||||
}
|
||||
},
|
||||
)
|
||||
is TranscriptRow.Single ->
|
||||
when (val item = row.item) {
|
||||
@@ -1573,6 +1658,16 @@ fun SessionScreen(
|
||||
::openImage,
|
||||
)
|
||||
},
|
||||
isWhole = { part ->
|
||||
Capped(item.id, part) in shownWholeCalls
|
||||
},
|
||||
onShowAll = { part ->
|
||||
toggleAnchored(row) {
|
||||
shownWholeCalls =
|
||||
shownWholeCalls +
|
||||
Capped(item.id, part)
|
||||
}
|
||||
},
|
||||
)
|
||||
is TranscriptItem.QuestionCard ->
|
||||
QuestionRow(item, ::answerAll)
|
||||
@@ -1757,7 +1852,10 @@ fun SessionScreen(
|
||||
input = it
|
||||
saveDraft(context, summary.id, it.text)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
// BenchRun's type phase requests focus on this exact field
|
||||
// (`composerFocus`)
|
||||
// so it types through the real composer rather than a stand-in.
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(composerFocus),
|
||||
// No longer "(+image)": the images are on screen above this, and a
|
||||
// placeholder saying so said it in words beside the thing itself.
|
||||
placeholder = { Text("Message") },
|
||||
@@ -1952,6 +2050,7 @@ fun SessionScreen(
|
||||
},
|
||||
onDismiss = { settingsOpen = false },
|
||||
onCopyRenderReport = copyRenderReport,
|
||||
onRunBenchmark = runBenchmark,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,13 @@ fun SessionSettingsDialog(
|
||||
* measures is that screen's own state.
|
||||
*/
|
||||
onCopyRenderReport: () -> Unit,
|
||||
/**
|
||||
* Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on
|
||||
* every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than
|
||||
* always-present-but-disabled: this has no meaning at all outside the bench build, and a
|
||||
* control with nothing behind it on every other build is not a state worth drawing.
|
||||
*/
|
||||
onRunBenchmark: (() -> Unit)? = null,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by remember(sessionId) { mutableStateOf(title) }
|
||||
@@ -508,6 +515,21 @@ fun SessionSettingsDialog(
|
||||
Text("Render timings", modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
||||
}
|
||||
// Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because
|
||||
// ui-trace and the emulator smoke run find it by that label, the same way every
|
||||
// other control here is found -- see AGENTS.md's "Driving the UI".
|
||||
onRunBenchmark?.let { run ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("P0 benchmark", modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = run) { Text("Run benchmark") }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Disabled rather than absent while there is nothing to save: a button that comes and goes
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.example.aiapp
|
||||
|
||||
/**
|
||||
* How much of a long thing the transcript draws before offering the rest behind a tap.
|
||||
*
|
||||
* One rule, four surfaces: a tool call's input, its output, and a user or assistant message. Kept
|
||||
* in one file because four copies would eventually disagree about what "too long" is -- and because
|
||||
* the Rust app answers the same question with the same numbers (`client-core`'s `text_cap.rs`, the
|
||||
* other half of this). The two are deliberately identical so that a benchmark comparing the apps is
|
||||
* comparing renderers rather than policies.
|
||||
*
|
||||
* **Lines and bytes both, whichever runs out first**, because they run out on different things: a
|
||||
* diff is thousands of short lines, a minified file or a base64 blob is one enormous one, and a cap
|
||||
* counting only one of them draws the whole of the other.
|
||||
*
|
||||
* **Cut at the head, keeping the beginning.** A tool's output is read from the top and the line
|
||||
* saying what went wrong is nearly always the first; a message is read from the top for the obvious
|
||||
* reason. (A path is identified by its other end -- none of these is a path.)
|
||||
*/
|
||||
object TextCap {
|
||||
/**
|
||||
* The bound on a verbatim block -- a tool call's input or its output. Short, because this text
|
||||
* is a machine's and the reader is looking for one line of it.
|
||||
*/
|
||||
const val VERBATIM_LINES = 80
|
||||
const val VERBATIM_BYTES = 4096
|
||||
|
||||
/**
|
||||
* The bound on a message. Larger than a verbatim block's in bytes and smaller in lines: prose
|
||||
* is read whole and wraps, so a screenful of it is far fewer lines than a screenful of a log,
|
||||
* and cutting a reply at 80 lines would cut most long answers that nobody would call long.
|
||||
*/
|
||||
const val MESSAGE_LINES = 200
|
||||
const val MESSAGE_BYTES = 16 * 1024
|
||||
}
|
||||
|
||||
/** [text] cut down to a bound, with the line count of the whole of it. See [cutText]. */
|
||||
data class CutText(
|
||||
/** What to draw. */
|
||||
val shown: String,
|
||||
/**
|
||||
* The line count of the **whole** text, not of [shown] -- it is what the "Show all N lines"
|
||||
* offer says, and a reader deciding whether to ask for the rest wants to know how much the rest
|
||||
* is.
|
||||
*/
|
||||
val lines: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* [text] cut to [maxLines] lines and [maxBytes] bytes, or `null` when the whole of it fits.
|
||||
*
|
||||
* Bytes rather than characters, so that this and the Rust half cut a multi-byte character at the
|
||||
* same place. UTF-8 is what the wire carries and what `client-core` measures.
|
||||
*/
|
||||
fun cutText(text: String, maxLines: Int, maxBytes: Int): CutText? {
|
||||
require(maxLines > 0 && maxBytes > 0) {
|
||||
"a cap of nothing shows an empty block and a 'Show all' for every value there is"
|
||||
}
|
||||
val bytes = text.toByteArray(Charsets.UTF_8)
|
||||
var byLines = -1
|
||||
var seen = 0
|
||||
for (i in text.indices) {
|
||||
if (text[i] == '\n') {
|
||||
seen++
|
||||
if (seen == maxLines) {
|
||||
byLines = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
val byBytes =
|
||||
if (bytes.size > maxBytes) {
|
||||
// Back up to a character boundary. A UTF-8 continuation byte is `10xxxxxx`; cutting on
|
||||
// one would split a character in half and `String(bytes)` would draw a replacement mark
|
||||
// where an em dash was.
|
||||
var end = maxBytes
|
||||
while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end--
|
||||
String(bytes, 0, end, Charsets.UTF_8).length
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
val cut =
|
||||
when {
|
||||
byLines >= 0 && byBytes >= 0 -> minOf(byLines, byBytes)
|
||||
byLines >= 0 -> byLines
|
||||
byBytes >= 0 -> byBytes
|
||||
else -> return null
|
||||
}
|
||||
return CutText(text.take(cut), lineCount(text))
|
||||
}
|
||||
|
||||
/**
|
||||
* How many lines [text] holds, counted the way Rust's `str::lines` counts them -- a trailing
|
||||
* newline ends the last line rather than starting an empty one.
|
||||
*
|
||||
* Said here rather than left to `lineSequence().count()`, which disagrees on exactly that case: the
|
||||
* two apps have to offer "Show all N lines" with the same N for the same message, and a count that
|
||||
* is one out on every text ending in a newline (which is most tool output) would show it.
|
||||
*/
|
||||
fun lineCount(text: String): Int =
|
||||
when {
|
||||
text.isEmpty() -> 0
|
||||
text.endsWith("\n") -> text.count { it == '\n' }
|
||||
else -> text.count { it == '\n' } + 1
|
||||
}
|
||||
|
||||
/** What a "Show all" offer says, so the wording is one string rather than one per surface. */
|
||||
fun showAllLabel(lines: Int): String = "Show all $lines lines"
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -9,6 +10,8 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.json.JSONObject
|
||||
@@ -105,33 +108,112 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
||||
*
|
||||
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
|
||||
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
|
||||
*
|
||||
* **Capped.** An `Edit`'s `old_string` and `new_string` arrive here whole and are routinely the
|
||||
* largest text on the screen, so the input is cut to [TextCap.VERBATIM_LINES] /
|
||||
* [TextCap. VERBATIM_BYTES] with a "Show all" under it -- one control for both blocks, because the
|
||||
* subject and the leftover fields are two halves of the same answer to "what was this call given",
|
||||
* and two would make the reader ask twice. [whole] is the reader having already asked.
|
||||
*/
|
||||
@Composable
|
||||
fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
||||
fun ToolInputView(
|
||||
tool: String,
|
||||
input: String,
|
||||
modifier: Modifier = Modifier,
|
||||
whole: Boolean = false,
|
||||
onShowAll: () -> Unit = {},
|
||||
) {
|
||||
val parsed = remember(tool, input) { parseToolInput(tool, input) }
|
||||
if (parsed.subject == null && parsed.rest.isEmpty()) return
|
||||
val subject = remember(parsed.subject, whole) { capped(parsed.subject, whole) }
|
||||
val rest =
|
||||
remember(parsed.rest, whole) {
|
||||
capped(parsed.rest.takeIf { it.isNotEmpty() }?.joinToString("\n"), whole)
|
||||
}
|
||||
RawBlock(modifier) {
|
||||
parsed.subject?.let { subject ->
|
||||
subject.shown?.let { shown ->
|
||||
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
|
||||
// one being read closely.
|
||||
Text(
|
||||
// Highlighted over what is *drawn* rather than over the whole subject, so a cut
|
||||
// cannot leave a span pointing past the end of the text it styles.
|
||||
//
|
||||
// Not cached: a tool's subject is one command line, which lexes in microseconds --
|
||||
// the cache exists for a fence with two hundred lines in it.
|
||||
remember(subject, parsed.language) { highlight(subject, parsed.language) },
|
||||
remember(shown, parsed.language) { highlight(shown, parsed.language) },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false,
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
parsed.rest.forEach {
|
||||
rest.shown?.let { shown ->
|
||||
// Never dropped: a field left out would be claiming the tool has no other input when it
|
||||
// might. Not wrapped, for the subject's reason -- Iris, 2026-09-08: "for 'raw' text
|
||||
// like
|
||||
// tool results I think it should not be wrapped".
|
||||
Text(
|
||||
it,
|
||||
shown,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
softWrap = false,
|
||||
modifier =
|
||||
Modifier.padding(top = 2.dp)
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
// The count is the whole input's, both blocks together, because that is what the one
|
||||
// control reveals.
|
||||
if (subject.cut || rest.cut) {
|
||||
ShowAllRow(lines = subject.lines + rest.lines, onClick = onShowAll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One verbatim block as it will be drawn; see [capped]. */
|
||||
data class CappedBlock(
|
||||
/** The text to draw, or `null` when there was none to begin with. */
|
||||
val shown: String?,
|
||||
/** The line count of the whole of it. */
|
||||
val lines: Int,
|
||||
/** Whether anything was left out. */
|
||||
val cut: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* [text] as an open card draws it: the whole of it when [whole], or [TextCap]'s worth otherwise.
|
||||
*
|
||||
* `null` in, `null` out, so a caller with nothing to draw reads the same three fields as one with
|
||||
* something.
|
||||
*/
|
||||
private fun capped(text: String?, whole: Boolean): CappedBlock {
|
||||
if (text == null) return CappedBlock(null, 0, false)
|
||||
val cut = if (whole) null else cutText(text, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
|
||||
return when (cut) {
|
||||
null -> CappedBlock(text, lineCount(text), false)
|
||||
else -> CappedBlock(cut.shown, cut.lines, true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Show all N lines" under a capped block.
|
||||
*
|
||||
* It says the count rather than "more" because the reader is deciding whether to ask for it: "Show
|
||||
* all 4,000 lines" and "Show all 12 lines" are different decisions, and "more" tells them apart not
|
||||
* at all.
|
||||
*/
|
||||
@Composable
|
||||
fun ShowAllRow(lines: Int, onClick: () -> Unit) {
|
||||
val label = showAllLabel(lines)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier =
|
||||
Modifier.padding(top = 4.dp).clickable(onClick = onClick).semantics {
|
||||
contentDescription = label
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CornerBasedShape
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.material3.Card
|
||||
@@ -163,6 +165,9 @@ fun ToolGroup(
|
||||
onToolToggle: (String) -> Unit,
|
||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||
image: @Composable (String) -> Unit,
|
||||
/** Which capped blocks the reader has asked to see whole; see [Capped]. */
|
||||
isWhole: (Capped) -> Boolean = { false },
|
||||
onShowAll: (Capped) -> Unit = {},
|
||||
) {
|
||||
val heading = "Called ${group.calls.size} tools"
|
||||
if (!expanded) {
|
||||
@@ -202,6 +207,8 @@ fun ToolGroup(
|
||||
onToggle = { onToolToggle(call.id) },
|
||||
onAnswer = onAnswer,
|
||||
image = image,
|
||||
isWhole = { part -> isWhole(Capped(call.id, part)) },
|
||||
onShowAll = { part -> onShowAll(Capped(call.id, part)) },
|
||||
shape = connectedShape(index, group.calls.size),
|
||||
)
|
||||
}
|
||||
@@ -272,6 +279,21 @@ private val GROUP_INSET = 4.dp
|
||||
/** Enough to read the join as a join rather than as one tall card. */
|
||||
private val GROUP_GAP = 2.dp
|
||||
|
||||
/**
|
||||
* Which half of an open card a cap and its "Show all" belong to.
|
||||
*
|
||||
* The two are capped and revealed independently: opening the whole of a call's input says nothing
|
||||
* about wanting the whole of its output, and one control revealing both would make the card jump by
|
||||
* the sum of two things when it was asked about one.
|
||||
*/
|
||||
enum class ToolPart {
|
||||
INPUT,
|
||||
OUTPUT,
|
||||
}
|
||||
|
||||
/** One capped thing on the screen that the reader may ask to see whole. */
|
||||
data class Capped(val call: String, val part: ToolPart)
|
||||
|
||||
/**
|
||||
* One tool call.
|
||||
*
|
||||
@@ -291,6 +313,11 @@ fun ToolCard(
|
||||
onToggle: () -> Unit,
|
||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||
image: @Composable (String) -> Unit = {},
|
||||
/**
|
||||
* Whether the reader has asked for the whole of this call's input or output; see [ToolPart].
|
||||
*/
|
||||
isWhole: (ToolPart) -> Boolean = { false },
|
||||
onShowAll: (ToolPart) -> Unit = {},
|
||||
/** Square where this card faces another in a group; see [connectedShape]. */
|
||||
shape: Shape = CardDefaults.shape,
|
||||
) {
|
||||
@@ -353,11 +380,28 @@ fun ToolCard(
|
||||
// something answerable; dumping the same JSON above them would be the decision
|
||||
// stated twice, once unreadably.
|
||||
if (tool.tool != ASK_USER_QUESTION) {
|
||||
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
||||
ToolInputView(
|
||||
tool.tool,
|
||||
tool.input,
|
||||
Modifier.padding(top = 4.dp),
|
||||
whole = isWhole(ToolPart.INPUT),
|
||||
onShowAll = { onShowAll(ToolPart.INPUT) },
|
||||
)
|
||||
}
|
||||
if (tool.output.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Output", style = MaterialTheme.typography.labelSmall)
|
||||
// Capped like the input, and revealed separately from it: a reader who wants
|
||||
// the whole of a 900-line `new_string` rarely also wants the whole of the build
|
||||
// log underneath it.
|
||||
val wholeOutput = isWhole(ToolPart.OUTPUT)
|
||||
val cut =
|
||||
remember(tool.output, wholeOutput) {
|
||||
if (wholeOutput) null
|
||||
else
|
||||
cutText(tool.output, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
|
||||
}
|
||||
val shown = cut?.shown ?: tool.output
|
||||
// What the tool printed, on the surface everything verbatim gets and in the
|
||||
// face it was written for: this is column-aligned far more often than it is
|
||||
// prose, and a proportional font silently destroys the alignment that carried
|
||||
@@ -367,13 +411,23 @@ fun ToolCard(
|
||||
// often the whole of what a diff or a test run is saying. Remembered against
|
||||
// the text, so a card that is open through a scroll parses once.
|
||||
val palette = remember { ansiPalette() }
|
||||
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
|
||||
val styled = remember(shown, palette) { ansiStyled(shown, palette) }
|
||||
RawBlock(Modifier.padding(top = 2.dp)) {
|
||||
// Not wrapped, and panning sideways instead -- Iris, 2026-09-08: "for 'raw'
|
||||
// text like tool results I think it should not be wrapped". Wrapping a
|
||||
// column-aligned log is what destroys the alignment that carried its
|
||||
// meaning, one line at a time and only on the long lines.
|
||||
Text(
|
||||
styled,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
if (cut != null) {
|
||||
ShowAllRow(cut.lines) { onShowAll(ToolPart.OUTPUT) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ package com.example.aiapp
|
||||
*
|
||||
* 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
|
||||
* a subagent -- see docs/SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
|
||||
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
|
||||
*/
|
||||
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
|
||||
|
||||
@@ -130,6 +130,26 @@ sealed class TranscriptUnit {
|
||||
get() = "u$seq:$ordinal"
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Show all N lines" under a message drawn only as far as [TextCap.MESSAGE_LINES].
|
||||
*
|
||||
* Its own unit rather than something inside the row above it, because the row above it is a
|
||||
* *bounded* item now and this is what says so -- and because a control that lives inside the
|
||||
* thing it reveals moves the moment it is pressed.
|
||||
*/
|
||||
data class ShowAll(
|
||||
override val seq: Long,
|
||||
override val ordinal: Int,
|
||||
/** The row this belongs to; what goes into the set of rows shown whole. */
|
||||
val row: Any,
|
||||
/** The line count of the whole message, which is what the offer says. */
|
||||
val lines: Int,
|
||||
override val gap: Dp,
|
||||
) : TranscriptUnit() {
|
||||
override val key: Any
|
||||
get() = "s$row"
|
||||
}
|
||||
|
||||
/** One memory note of a settled reply; see [MemoryNote]. */
|
||||
data class Memory(
|
||||
override val seq: Long,
|
||||
@@ -142,6 +162,36 @@ sealed class TranscriptUnit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A message row cut to [TextCap]'s worth of itself, and the line count of the whole of it.
|
||||
*
|
||||
* The cut happens **before** the flatten below decides how to draw the row, so everything after it
|
||||
* -- pieces, chunks, warming -- sees a shorter message and needs to know nothing about caps. The
|
||||
* shortened row keeps its key and its seq, so the list's identity and every saved scroll anchor are
|
||||
* untouched by a reader opening or closing one.
|
||||
*
|
||||
* A reply still arriving is never capped: it grows by deltas, and a row that stopped growing at two
|
||||
* hundred lines while the model was plainly still writing would read as the stream having died.
|
||||
* `iris`'s `row::build_row` states the same rule for the same reason.
|
||||
*/
|
||||
private fun capRow(row: TranscriptRow, shownWhole: Set<Any>): Pair<TranscriptRow, Int?> {
|
||||
val item = (row as? TranscriptRow.Single)?.item ?: return row to null
|
||||
if (row.key in shownWhole) return row to null
|
||||
val cut =
|
||||
when {
|
||||
item is TranscriptItem.UserMsg ->
|
||||
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
|
||||
it to TranscriptRow.Single(item.copy(text = it.shown))
|
||||
}
|
||||
item is TranscriptItem.AssistantMsg && item.settled ->
|
||||
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
|
||||
it to TranscriptRow.Single(item.copy(text = it.shown))
|
||||
}
|
||||
else -> null
|
||||
} ?: return row to null
|
||||
return cut.second to cut.first.lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
|
||||
* screen, which is what a reversed lazy list calls the start.
|
||||
@@ -161,11 +211,14 @@ fun transcriptUnits(
|
||||
rows: List<TranscriptRow>,
|
||||
replies: ParsedReplies,
|
||||
openNotes: Set<Long>,
|
||||
shownWhole: Set<Any> = emptySet(),
|
||||
): List<TranscriptUnit> {
|
||||
val started = System.nanoTime()
|
||||
val units = ArrayList<TranscriptUnit>(rows.size)
|
||||
rows.forEachIndexed { index, row ->
|
||||
rows.forEachIndexed { index, whole ->
|
||||
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
|
||||
val (row, hidden) = capRow(whole, shownWhole)
|
||||
val rowStart = units.size
|
||||
val item = (row as? TranscriptRow.Single)?.item
|
||||
if (item is TranscriptItem.PeerNote) {
|
||||
val open = item.seq in openNotes
|
||||
@@ -240,6 +293,16 @@ fun transcriptUnits(
|
||||
} else {
|
||||
units += TranscriptUnit.Whole(row, rowGap)
|
||||
}
|
||||
if (hidden != null) {
|
||||
units +=
|
||||
TranscriptUnit.ShowAll(
|
||||
row.startSeq,
|
||||
units.size - rowStart,
|
||||
row.key,
|
||||
hidden,
|
||||
BLOCK_SPACING,
|
||||
)
|
||||
}
|
||||
}
|
||||
units.reverse()
|
||||
reportDuplicateKeys(units)
|
||||
@@ -266,11 +329,18 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex
|
||||
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
|
||||
* ready parses.
|
||||
*/
|
||||
fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> =
|
||||
rows.mapIndexedNotNull { index, row ->
|
||||
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
|
||||
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
|
||||
}
|
||||
fun unwarmedReplies(
|
||||
rows: List<TranscriptRow>,
|
||||
replies: ParsedReplies,
|
||||
shownWhole: Set<Any> = emptySet(),
|
||||
): List<TranscriptItem> = rows.mapIndexedNotNull { index, whole ->
|
||||
// The *capped* row's text, since that is what the flatten will draw and so what has to be
|
||||
// ready: a capped row draws its head, which is a different string from the message and so a
|
||||
// different cache entry.
|
||||
val row = capRow(whole, shownWhole).first
|
||||
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
|
||||
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Above this many characters, a user message is drawn in slices rather than as one bubble.
|
||||
@@ -375,6 +445,7 @@ private val TranscriptUnit?.kind: String
|
||||
is TranscriptUnit.PeerBlock -> "peer block"
|
||||
is TranscriptUnit.UserChunk -> "user slice"
|
||||
is TranscriptUnit.Memory -> "memory note"
|
||||
is TranscriptUnit.ShowAll -> "show all"
|
||||
is TranscriptUnit.Whole ->
|
||||
when (val row = row) {
|
||||
is TranscriptRow.Tools -> "tool group"
|
||||
|
||||
@@ -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,63 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* The cap rule, which this app and the Rust one have to answer identically -- these mirror
|
||||
* `client-core/src/text_cap.rs`'s own tests case for case, because "Show all 4,000 lines" appearing
|
||||
* in one app and "Show all 4,001" in the other is exactly the kind of difference a benchmark
|
||||
* comparing the two would report as a rendering difference.
|
||||
*/
|
||||
class TextCapTest {
|
||||
@Test
|
||||
fun textUnderBothBoundsIsNotCut() {
|
||||
assertNull(cutText("one\ntwo\nthree", 80, 4096))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLineBoundCutsAtALineBoundary() {
|
||||
val cut = cutText("a\nb\nc\nd\n", 2, 4096)!!
|
||||
assertEquals("a\nb", cut.shown)
|
||||
assertEquals(4, cut.lines, "the count is the whole text's, not the shown part's")
|
||||
}
|
||||
|
||||
/**
|
||||
* The half the line bound cannot catch: one enormous line, which is what a minified file is.
|
||||
*/
|
||||
@Test
|
||||
fun theByteBoundCutsOneLongLine() {
|
||||
val cut = cutText("x".repeat(5000), 80, 4096)!!
|
||||
assertEquals(4096, cut.shown.length)
|
||||
assertEquals(1, cut.lines)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTighterOfTheTwoBoundsWins() {
|
||||
val text = "aaaa\n".repeat(100)
|
||||
assertEquals(100, cutText(text, 80, 100)!!.shown.length)
|
||||
assertEquals("aaaa\naaaa\naaaa\naaaa", cutText(text, 4, 4096)!!.shown)
|
||||
}
|
||||
|
||||
/**
|
||||
* A cut landing inside a multi-byte character has to back up to the boundary. The Rust half
|
||||
* measures in UTF-8 bytes, so this one does too -- counting UTF-16 characters instead would cut
|
||||
* the same text at a different place in every message with an em dash in it.
|
||||
*/
|
||||
@Test
|
||||
fun aCutInsideAMultibyteCharacterBacksUpToTheBoundary() {
|
||||
val cut = cutText("é".repeat(100), 80, 11)!!
|
||||
assertEquals("é".repeat(5), cut.shown, "11 bytes lands mid-character; 10 is the cut")
|
||||
}
|
||||
|
||||
/** A trailing newline ends the last line rather than starting an empty one; see [lineCount]. */
|
||||
@Test
|
||||
fun lineCountMatchesRustsStrLines() {
|
||||
assertEquals(0, lineCount(""))
|
||||
assertEquals(1, lineCount("a"))
|
||||
assertEquals(1, lineCount("a\n"))
|
||||
assertEquals(2, lineCount("a\nb"))
|
||||
assertEquals(2, lineCount("a\nb\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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,603 events. The first 3,202 (`BACKLOG_COUNT`) are the scrolled-back
|
||||
history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output,
|
||||
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."
|
||||
|
||||
**The streamed reply has a blank line every few deltas** (2026-09-09), so the markdown block a
|
||||
delta lands in stays the size a real reply's blocks are -- 53 blocks, longest 502 characters,
|
||||
against a measured p50 of 147 and a largest-ever 1,580 over 7,706 blocks of real assistant
|
||||
messages. It used to be one run-on 14,888-character block, and since a row re-shapes the block a
|
||||
delta lands in, every delta re-shaped all of it: quadratic in the reply's length, and 9.5ms of
|
||||
frame time on a phone spent on a shape that does not occur. docs/RUST.md's "Incremental text"
|
||||
has the measurements.
|
||||
|
||||
**The run-on message is kept**, as the first two events of the backlog: 14,824 characters in a
|
||||
single block, just under `text_cap`'s 16 KiB `MESSAGE_BYTES` so it draws in full rather than
|
||||
behind a "Show all". It is deliberately *not* streamed -- the repeated-reshape pathology needs a
|
||||
growing block, and that lives in `app-rust/tests/frame_profile.rs` where it can be iterated on
|
||||
in a second rather than in a two-minute phone run. It is emitted with the random state saved and
|
||||
restored around it, so adding it left every other backlog event byte-identical; that is what
|
||||
keeps `phone_screen.rs`'s recorded gestures landing on the content they were recorded against.
|
||||
- `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.
|
||||
|
||||
`BACKLOG_COUNT` lives in three places and moves in all of them or none: here, `app-rust/src/ui/
|
||||
fixture.rs` and `BenchFixture.kt`. The split is by line index, so a stale copy makes that app open
|
||||
a different half of the file.
|
||||
|
||||
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 |
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user