Compare commits
147
Commits
| 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 |
No files matched your search
+7
-3
@@ -1,6 +1,10 @@
|
|||||||
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
||||||
# every crate in this repo into one workspace -- they are deliberately
|
# every crate in this repo into one workspace -- they are deliberately
|
||||||
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
|
# independent (see scripts/run-tests.sh, which cds into each).
|
||||||
# from the repo root runs xtask/src/main.rs directly.
|
#
|
||||||
|
# `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]
|
[alias]
|
||||||
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
|
xtask = "run --quiet --manifest-path scripts/xtask/Cargo.toml --"
|
||||||
+5
-3
@@ -64,9 +64,11 @@ components: [
|
|||||||
// defaults to this checkout's root, which both the `cargo xtask`
|
// defaults to this checkout's root, which both the `cargo xtask`
|
||||||
// alias (`.cargo/config.toml`, resolved relative to the working
|
// alias (`.cargo/config.toml`, resolved relative to the working
|
||||||
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
||||||
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
|
// step (`scripts/build/outputs/apk/<mode>/*.apk`, matching
|
||||||
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
|
// discover.rs's `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's
|
||||||
// both need.
|
// 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(
|
Apk(
|
||||||
name: "shell",
|
name: "shell",
|
||||||
modes: ["release", "debug"],
|
modes: ["release", "debug"],
|
||||||
|
|||||||
+6
-9
@@ -9,8 +9,7 @@ local.properties
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
server/target/
|
server/target/
|
||||||
event-model/target/
|
event-model/target/
|
||||||
client-core/target/
|
app-rust/target/
|
||||||
android-shell/target/
|
|
||||||
|
|
||||||
# E3's native library, built by cargo-ndk straight into the Gradle module
|
# E3's native library, built by cargo-ndk straight into the Gradle module
|
||||||
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
||||||
@@ -32,11 +31,9 @@ sessions/
|
|||||||
|
|
||||||
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
||||||
iris/target/
|
iris/target/
|
||||||
iris/android-app/target/
|
|
||||||
|
|
||||||
# E5's packaging xtask (RUST.md). `build/` above already covers
|
# The packaging xtask and the GPU rigs, both under scripts/. `build/`
|
||||||
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
|
# above already covers scripts/build/outputs/apk, where `cargo xtask apk`
|
||||||
# The repo root has no Cargo workspace, so this is xtask's own
|
# publishes for Dev Updater to find.
|
||||||
# intermediate working files (target/xtask/apk/...), not a shared one.
|
scripts/xtask/target/
|
||||||
xtask/target/
|
scripts/rigs/gpu-probe/target/
|
||||||
/target/
|
|
||||||
@@ -19,6 +19,28 @@ 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,
|
is a new driver — never a session-type branch in shared code (routes,
|
||||||
transcript, app screens).
|
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
|
## Layout
|
||||||
|
|
||||||
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
||||||
@@ -29,11 +51,46 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
|
|||||||
|
|
||||||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
||||||
comment is the HTTP table and the surface's source of truth.
|
comment is the HTTP table and the surface's source of truth.
|
||||||
|
- `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".
|
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
||||||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
||||||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
||||||
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
||||||
the Keystore-sealed token.
|
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
|
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
||||||
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
||||||
binding and the certificate's SANs (`netif`), owner-only files (`private`),
|
binding and the certificate's SANs (`netif`), owner-only files (`private`),
|
||||||
@@ -51,18 +108,53 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
|
|||||||
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
|
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
|
||||||
the opening and stream effects in `SessionScreen.kt`.
|
the opening and stream effects in `SessionScreen.kt`.
|
||||||
- `docs/TODO.md` — the working list.
|
- `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`
|
- `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
|
||||||
branch of the `ai-app-2` clone): what has to be reproduced, the
|
branch of the `ai-app-2` clone): what has to be reproduced, the
|
||||||
framework decision, and the ordered experiments with their pass
|
framework decision, and the ordered experiments with their pass
|
||||||
conditions. Read it before touching anything under that branch.
|
conditions. Read it before touching anything under that branch.
|
||||||
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`,
|
- `docs/IRIS_TODO.md`, `docs/LAYOUT.md`, `docs/TEXTURES.md`,
|
||||||
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's
|
`docs/CLIENT_CORE.md` — iris's open working list, its layout/render
|
||||||
own public API log, working list, decisions log, layout/render design,
|
design, its texture-atlas design, and the design of `app-rust`'s
|
||||||
and texture-atlas design, and the client-core crate's design,
|
`client` module, respectively.
|
||||||
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
|
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
||||||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
||||||
rather than a script kept here) and the APK, in parallel. It points at
|
rather than a script kept here), 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
|
`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
|
`~/.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
|
offer them. Note what deleting the config directory takes with it: the CA
|
||||||
@@ -85,12 +177,33 @@ 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
|
two icon buttons the same width without either being given one — and why
|
||||||
`GLYPH_SIZE` is smaller than it looks like it should be.
|
`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
|
## Checking your work
|
||||||
|
|
||||||
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
|
- **Commit completed work.** Once a coherent piece of work has passed its
|
||||||
`server/`), plus `cargo clippy --all-targets` and `cargo fmt`. The build
|
relevant checks and has no known major issue or unresolved design decision,
|
||||||
stays warning-clean and rustfmt-clean at the defaults — there is no
|
commit it rather than leaving it in the worktree. Keep independently
|
||||||
`rustfmt.toml` and there should not be one.
|
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/`,
|
- **App**: from `app/`,
|
||||||
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
||||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||||||
@@ -151,7 +264,7 @@ 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
|
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
|
place "how far had this phone fallen behind" is answerable — the app sees a
|
||||||
window arrive and cannot tell.
|
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
|
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
|
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
||||||
involved. That is how to verify the wg0-only posture.
|
involved. That is how to verify the wg0-only posture.
|
||||||
@@ -161,7 +274,7 @@ two icon buttons the same width without either being given one — and why
|
|||||||
Each exists because something was invisible without it.
|
Each exists because something was invisible without it.
|
||||||
|
|
||||||
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
|
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
|
||||||
and DECISIONS.md's 2026-09-05 entries), the phone benchmark gate Iris
|
and the 2026-09-05 decisions), the phone benchmark gate Iris
|
||||||
asked for before porting continues: a deterministic, checked-in synthetic
|
asked for before porting continues: a deterministic, checked-in synthetic
|
||||||
transcript (`app/bench-fixture/generate.py`, never a real one) that both
|
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
|
this app and iris open with no server, so a frame-time comparison
|
||||||
@@ -261,13 +374,113 @@ Each exists because something was invisible without it.
|
|||||||
framework, from `atrace` text output with no trace processor needed. It is
|
framework, from `atrace` text output with no trace processor needed. It is
|
||||||
how the cost of a layout node per link was attributed to the framework
|
how the cost of a layout node per link was attributed to the framework
|
||||||
rather than guessed at.
|
rather than guessed at.
|
||||||
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features
|
- **`app-rust/build-apk.sh [debug|release] [--abi ...] [--features
|
||||||
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK
|
...]`** builds the Rust app's cdylib (`cargo ndk` from `app-rust/`,
|
||||||
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and
|
straight into `android-project/app/src/main/jniLibs/`) and its APK
|
||||||
**`iris/android-app/run-bench.sh [--apk PATH]`** installs it on this
|
(Gradle, from `android-project/`) in one step and verifies the result
|
||||||
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
(`aapt2`/`apksigner`), and **`app-rust/run-bench.sh [--apk PATH]`**
|
||||||
report -- written so the P0 build/install/tap/read-report cycle stops
|
installs it on this checkout's own emulator, taps "Run benchmark" by
|
||||||
being retyped by hand each time (docs/RUST.md's P0 box).
|
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
|
### Driving the UI
|
||||||
|
|
||||||
@@ -333,13 +546,11 @@ thing to suspect first if a remote spawn ever mangles an argument.
|
|||||||
|
|
||||||
## Where things run (host vs this VM)
|
## Where things run (host vs this VM)
|
||||||
|
|
||||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
This checkout runs in a VM while production runs on its host:
|
||||||
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that
|
|
||||||
means here:
|
|
||||||
|
|
||||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||||||
address the phone can reach is, and where WireGuard terminates.
|
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>`.
|
there with `sudo WG_ENDPOINT=<ddns name>`.
|
||||||
- **The tunnel and the real phone can never terminate in the VM**, because
|
- **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.
|
nothing outside can open a connection into it. Phone bring-up is host work.
|
||||||
@@ -427,8 +638,7 @@ where it was instead of half-deleted.
|
|||||||
|
|
||||||
## Things that have bitten
|
## Things that have bitten
|
||||||
|
|
||||||
Project-specific only — a lesson that would bite any project on this machine
|
Project-specific only; keep cross-project machine notes out of this file.
|
||||||
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
|
||||||
|
|
||||||
- **tracing caches callsite interest process-wide.** A test that hits a
|
- **tracing caches callsite interest process-wide.** A test that hits a
|
||||||
`tracing::warn!` with no subscriber installed can poison the interest cache
|
`tracing::warn!` with no subscriber installed can poison the interest cache
|
||||||
|
|||||||
@@ -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.
|
|
||||||
Generated
-1081
File diff suppressed because it is too large.
Load diff
@@ -1,36 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "android-shell"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
|
|
||||||
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
|
|
||||||
# why those two classes cannot be anything but Java/Kotlin, registered from
|
|
||||||
# the manifest by name). Everything they would otherwise have done in
|
|
||||||
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
|
|
||||||
# picking a session for a share -- is here instead, built on `client-core`
|
|
||||||
# so the networking and parsing are not duplicated a third time next to the
|
|
||||||
# server and the Kotlin app.
|
|
||||||
#
|
|
||||||
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
|
|
||||||
# on a normal host target without an Android NDK toolchain, the same
|
|
||||||
# posture `client-core` and `server` already have.
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "android_shell"
|
|
||||||
crate-type = ["cdylib", "lib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
client-core = { path = "../client-core" }
|
|
||||||
jni = "0.22"
|
|
||||||
log = "0.4"
|
|
||||||
|
|
||||||
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
|
|
||||||
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
|
|
||||||
# without a backend installed -- so without this, every recoverable error
|
|
||||||
# at a native entry point would be silently dropped rather than reaching
|
|
||||||
# logcat. Android-only: nothing else here needs it, and it does not build
|
|
||||||
# off-device (see `notify::ensure_logger`'s call site, the only place this
|
|
||||||
# is used).
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
|
||||||
android_logger = "0.15"
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
//! Thin wrappers around the five `Env` calls this crate makes constantly
|
|
||||||
//! (a class name, a method name and a signature, all as plain `&str`).
|
|
||||||
//!
|
|
||||||
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
|
|
||||||
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
|
|
||||||
//! here uniformly rather than switching to the compile-time `jni_str!`
|
|
||||||
//! literal macro call by call -- these are a handful of short, one-off
|
|
||||||
//! lookups, not a hot loop, so the difference is not worth two code paths
|
|
||||||
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
|
|
||||||
//! `FieldSignature`, which is why those go through
|
|
||||||
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
|
|
||||||
//! parsed form is what lets these calls skip re-validating the signature
|
|
||||||
//! against the arguments on every call, which is the whole reason `jni`
|
|
||||||
//! moved to it.
|
|
||||||
//!
|
|
||||||
//! **The classloader gotcha, found by testing (2026-09-05).** A class
|
|
||||||
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
|
|
||||||
//! `get_static_field` -- anything that resolves a *class*, as opposed to
|
|
||||||
//! `call_method` on an object it already has, which needs no such lookup)
|
|
||||||
//! defaults to `FindClass`'s ordinary search when it cannot find the
|
|
||||||
//! calling thread a classloader through `Thread.getContextClassLoader()`.
|
|
||||||
//! That default is fine on a thread the JVM itself started -- an
|
|
||||||
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
|
|
||||||
//! from `android-shell`'s own background thread (the notification
|
|
||||||
//! follow-loop, the share upload) is running on a thread *Rust* spawned
|
|
||||||
//! and attached with `JavaVM::attach_current_thread`, which the platform
|
|
||||||
//! never gave an app classloader. Framework classes
|
|
||||||
//! (`android.app.Notification$Builder`, ...) still resolve, because they
|
|
||||||
//! are reachable from the bootstrap loader `FindClass` falls back to --
|
|
||||||
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
|
|
||||||
//! packaged inside this app's own APK. The failure was
|
|
||||||
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
|
|
||||||
//! as "failed to resolve Java class ... (class not found or linkage
|
|
||||||
//! error)" -- on a real device this reads as "the notification silently
|
|
||||||
//! never arrives," since the whole call is inside the follow loop and the
|
|
||||||
//! ongoing foreground notification (built on the main thread, in
|
|
||||||
//! `try_start`, before the background thread exists) posts fine either
|
|
||||||
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
|
|
||||||
//! first time any entry point has a `Context` to ask, and every class
|
|
||||||
//! lookup below goes through it explicitly via `LoaderContext::Loader`
|
|
||||||
//! rather than the thread-dependent default -- so it is correct on the
|
|
||||||
//! main thread and on this crate's own background threads alike.
|
|
||||||
|
|
||||||
use jni::Env;
|
|
||||||
use jni::errors::Result;
|
|
||||||
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
|
|
||||||
use jni::refs::{Global, LoaderContext};
|
|
||||||
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
|
|
||||||
use jni::strings::JNIString;
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Caches `context`'s own `ClassLoader`, the first time this is called.
|
|
||||||
/// Cheap to call from every entry point that has a `Context` on hand
|
|
||||||
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
|
|
||||||
/// a `OnceLock::get` and nothing else.
|
|
||||||
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
|
||||||
if CLASS_LOADER.get().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// context.getClass().getClassLoader() -- resolved via `call_method` on
|
|
||||||
// real objects throughout, so this needs no class-name lookup of its
|
|
||||||
// own and has nothing to bootstrap.
|
|
||||||
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
|
|
||||||
let loader_obj = call_method(
|
|
||||||
env,
|
|
||||||
&class_obj,
|
|
||||||
"getClassLoader",
|
|
||||||
"()Ljava/lang/ClassLoader;",
|
|
||||||
&[],
|
|
||||||
)?
|
|
||||||
.l()?;
|
|
||||||
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
|
|
||||||
let global = env.new_global_ref(&loader)?;
|
|
||||||
// Lost the race with another entry point calling this concurrently --
|
|
||||||
// both loaders name the same app, so either one is fine and there is
|
|
||||||
// nothing to reconcile.
|
|
||||||
let _ = CLASS_LOADER.set(global);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
|
|
||||||
/// through the cached app classloader when one has been remembered, and
|
|
||||||
/// through the ordinary default otherwise -- which is every call made
|
|
||||||
/// before any entry point has run, and is also correct for a main-thread
|
|
||||||
/// caller, so there is no case this makes worse.
|
|
||||||
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
|
||||||
match CLASS_LOADER.get() {
|
|
||||||
Some(loader) => {
|
|
||||||
let binary_name = name.replace('/', ".");
|
|
||||||
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
|
|
||||||
}
|
|
||||||
None => env.find_class(JNIString::new(name)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
|
||||||
resolve_class(env, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A new Java string as a plain `JObject` -- what every call site here
|
|
||||||
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
|
|
||||||
/// `JString: Into<JObject>` is the documented way across).
|
|
||||||
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
|
|
||||||
Ok(env.new_string(text)?.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_object<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
class: &str,
|
|
||||||
sig: &str,
|
|
||||||
args: &[JValue],
|
|
||||||
) -> Result<JObject<'local>> {
|
|
||||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
||||||
let class = resolve_class(env, class)?;
|
|
||||||
env.new_object(class, sig.method_signature(), args)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn call_method<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
obj: &JObject,
|
|
||||||
method: &str,
|
|
||||||
sig: &str,
|
|
||||||
args: &[JValue],
|
|
||||||
) -> Result<JValueOwned<'local>> {
|
|
||||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
||||||
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn call_static_method<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
class: &str,
|
|
||||||
method: &str,
|
|
||||||
sig: &str,
|
|
||||||
args: &[JValue],
|
|
||||||
) -> Result<JValueOwned<'local>> {
|
|
||||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
||||||
let class = resolve_class(env, class)?;
|
|
||||||
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_static_field<'local>(
|
|
||||||
env: &mut Env<'local>,
|
|
||||||
class: &str,
|
|
||||||
field: &str,
|
|
||||||
sig: &str,
|
|
||||||
) -> Result<JValueOwned<'local>> {
|
|
||||||
let sig = RuntimeFieldSignature::from_str(sig)?;
|
|
||||||
let class = resolve_class(env, class)?;
|
|
||||||
env.get_static_field(class, JNIString::new(field), sig.field_signature())
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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
|
||||||
+186
-206
@@ -166,6 +166,28 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ai-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"android-view",
|
||||||
|
"android_logger",
|
||||||
|
"base64",
|
||||||
|
"event-model",
|
||||||
|
"iris",
|
||||||
|
"jni 0.22.4",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"pulldown-cmark",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tabs-ui",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"ureq",
|
||||||
|
"winit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aligned"
|
name = "aligned"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -566,18 +588,18 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-vec",
|
"bit-vec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-vec"
|
name = "bit-vec"
|
||||||
version = "0.8.0"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit_field"
|
name = "bit_field"
|
||||||
@@ -606,12 +628,6 @@ dependencies = [
|
|||||||
"no_std_io2",
|
"no_std_io2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "block"
|
|
||||||
version = "0.1.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -621,6 +637,15 @@ dependencies = [
|
|||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block2"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "blocking"
|
name = "blocking"
|
||||||
version = "1.7.0"
|
version = "1.7.0"
|
||||||
@@ -740,16 +765,6 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "client-core"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"event-model",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"ureq",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -761,9 +776,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.12.0"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
|
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
@@ -834,16 +849,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "core-foundation"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
|
||||||
dependencies = [
|
|
||||||
"core-foundation-sys",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -857,8 +862,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation",
|
||||||
"core-graphics-types 0.1.3",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -870,18 +875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation",
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "core-graphics-types"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"core-foundation 0.10.1",
|
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -896,9 +890,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.7"
|
version = "0.8.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-epoch",
|
"crossbeam-epoch",
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
@@ -906,18 +900,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.20"
|
version = "0.9.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.22"
|
version = "0.8.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crunchy"
|
name = "crunchy"
|
||||||
@@ -1182,9 +1176,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "font-types"
|
name = "font-types"
|
||||||
version = "0.12.4"
|
version = "0.12.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23"
|
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
@@ -1387,9 +1381,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glow"
|
name = "glow"
|
||||||
version = "0.16.0"
|
version = "0.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
|
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"slotmap",
|
"slotmap",
|
||||||
@@ -1420,26 +1414,6 @@ dependencies = [
|
|||||||
"windows",
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gpu-descriptor"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"gpu-descriptor-types",
|
|
||||||
"hashbrown 0.15.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gpu-descriptor-types"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "half"
|
name = "half"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@@ -1505,12 +1479,6 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hexf-parse"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1759,23 +1727,6 @@ dependencies = [
|
|||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "iris-android-app"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"android-view",
|
|
||||||
"android_logger",
|
|
||||||
"client-core",
|
|
||||||
"event-model",
|
|
||||||
"iris",
|
|
||||||
"libc",
|
|
||||||
"log",
|
|
||||||
"serde_json",
|
|
||||||
"tabs-ui",
|
|
||||||
"tokio",
|
|
||||||
"transcript-ui",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iris-core"
|
name = "iris-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1796,7 +1747,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1983,7 +1934,7 @@ dependencies = [
|
|||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"libc",
|
"libc",
|
||||||
"plain",
|
"plain",
|
||||||
"redox_syscall 0.9.3",
|
"redox_syscall 0.9.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2040,15 +1991,6 @@ dependencies = [
|
|||||||
"imgref",
|
"imgref",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "malloc_buf"
|
|
||||||
version = "0.0.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maybe-rayon"
|
name = "maybe-rayon"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2083,21 +2025,6 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "metal"
|
|
||||||
version = "0.33.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"block",
|
|
||||||
"core-graphics-types 0.2.0",
|
|
||||||
"foreign-types",
|
|
||||||
"log",
|
|
||||||
"objc",
|
|
||||||
"paste",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2130,9 +2057,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "naga"
|
name = "naga"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135"
|
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
@@ -2141,11 +2068,11 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"codespan-reporting",
|
"codespan-reporting",
|
||||||
"half",
|
"half",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.17.1",
|
||||||
"hexf-parse",
|
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"libm",
|
"libm",
|
||||||
"log",
|
"log",
|
||||||
|
"naga-types",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
@@ -2154,6 +2081,18 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "naga-types"
|
||||||
|
version = "30.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
"indexmap",
|
||||||
|
"rustc-hash",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2303,15 +2242,6 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc"
|
|
||||||
version = "0.2.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
|
||||||
dependencies = [
|
|
||||||
"malloc_buf",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc-sys"
|
name = "objc-sys"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
@@ -2344,13 +2274,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
"objc2-core-image",
|
"objc2-core-image",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-quartz-core",
|
"objc2-quartz-core 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2372,7 +2302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2384,7 +2314,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2396,7 +2326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2431,10 +2361,10 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-metal",
|
"objc2-metal 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2443,7 +2373,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-contacts",
|
"objc2-contacts",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2472,7 +2402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
@@ -2506,7 +2436,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit 0.2.2",
|
"objc2-app-kit 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2519,11 +2449,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-metal"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2531,10 +2473,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-metal",
|
"objc2-metal 0.2.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-quartz-core"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"objc2-metal 0.3.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2554,7 +2510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-cloud-kit",
|
"objc2-cloud-kit",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
@@ -2562,7 +2518,7 @@ dependencies = [
|
|||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-link-presentation",
|
"objc2-link-presentation",
|
||||||
"objc2-quartz-core",
|
"objc2-quartz-core 0.2.2",
|
||||||
"objc2-symbols",
|
"objc2-symbols",
|
||||||
"objc2-uniform-type-identifiers",
|
"objc2-uniform-type-identifiers",
|
||||||
"objc2-user-notifications",
|
"objc2-user-notifications",
|
||||||
@@ -2574,7 +2530,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2586,7 +2542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2858,9 +2814,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pollster"
|
name = "pollster"
|
||||||
version = "0.4.0"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
@@ -3139,6 +3095,18 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "raw-window-metal"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"objc2-quartz-core 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -3196,9 +3164,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.9.3"
|
version = "0.9.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5"
|
checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
]
|
]
|
||||||
@@ -3310,9 +3278,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -3569,9 +3537,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spirv"
|
name = "spirv"
|
||||||
version = "0.3.0+sdk-1.3.268.0"
|
version = "0.4.0+sdk-1.4.341.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
|
checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
]
|
]
|
||||||
@@ -3863,17 +3831,6 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "transcript-ui"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"client-core",
|
|
||||||
"event-model",
|
|
||||||
"iris",
|
|
||||||
"log",
|
|
||||||
"pulldown-cmark",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tree_magic_mini"
|
name = "tree_magic_mini"
|
||||||
version = "3.2.2"
|
version = "3.2.2"
|
||||||
@@ -3944,9 +3901,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ureq"
|
name = "ureq"
|
||||||
version = "3.4.0"
|
version = "3.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
@@ -3964,9 +3921,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ureq-proto"
|
name = "ureq-proto"
|
||||||
version = "0.6.1"
|
version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"http",
|
"http",
|
||||||
@@ -4252,9 +4209,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu"
|
name = "wgpu"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9cb534d5ffd109c7d1135f34cdae29e60eab94855a625dcfe1705f8bc7ad79f"
|
checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
@@ -4262,7 +4219,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"document-features",
|
"document-features",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.17.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"naga",
|
"naga",
|
||||||
@@ -4282,9 +4239,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core"
|
name = "wgpu-core"
|
||||||
version = "28.0.1"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d23f4642f53f666adcfd2d3218ab174d1e6681101aef18696b90cbe64d1c10f9"
|
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
@@ -4293,10 +4250,11 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"document-features",
|
"document-features",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.17.1",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"naga",
|
"naga",
|
||||||
|
"naga-types",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
@@ -4309,66 +4267,70 @@ dependencies = [
|
|||||||
"wgpu-core-deps-emscripten",
|
"wgpu-core-deps-emscripten",
|
||||||
"wgpu-core-deps-windows-linux-android",
|
"wgpu-core-deps-windows-linux-android",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
|
"wgpu-naga-bridge",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-apple"
|
name = "wgpu-core-deps-apple"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "87b7b696b918f337c486bf93142454080a32a37832ba8a31e4f48221890047da"
|
checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-emscripten"
|
name = "wgpu-core-deps-emscripten"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34b251c331f84feac147de3c4aa3aa45112622a95dd7ee1b74384fa0458dbd79"
|
checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-windows-linux-android"
|
name = "wgpu-core-deps-windows-linux-android"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ca976e72b2c9964eb243e281f6ce7f14a514e409920920dcda12ae40febaae"
|
checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-hal"
|
name = "wgpu-hal"
|
||||||
version = "28.0.1"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44d6cb474beb218824dcc9e1ce679d973f719262789bfb27407da560cac20eeb"
|
checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"ash",
|
"ash",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block",
|
"block2 0.6.2",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"core-graphics-types 0.2.0",
|
|
||||||
"glow",
|
"glow",
|
||||||
"glutin_wgl_sys",
|
"glutin_wgl_sys",
|
||||||
"gpu-allocator",
|
"gpu-allocator",
|
||||||
"gpu-descriptor",
|
"hashbrown 0.17.1",
|
||||||
"hashbrown 0.16.1",
|
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"khronos-egl",
|
"khronos-egl",
|
||||||
"libc",
|
"libc",
|
||||||
"libloading",
|
"libloading",
|
||||||
"log",
|
"log",
|
||||||
"metal",
|
|
||||||
"naga",
|
"naga",
|
||||||
|
"naga-types",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"objc",
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"objc2-metal 0.3.2",
|
||||||
|
"objc2-quartz-core 0.3.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ordered-float",
|
"ordered-float",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -4377,26 +4339,44 @@ dependencies = [
|
|||||||
"profiling",
|
"profiling",
|
||||||
"range-alloc",
|
"range-alloc",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
|
"raw-window-metal",
|
||||||
"renderdoc-sys",
|
"renderdoc-sys",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
|
"static_assertions",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
"wayland-sys",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"wgpu-naga-bridge",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core",
|
"windows-core",
|
||||||
|
"windows-result",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wgpu-naga-bridge"
|
||||||
|
version = "30.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081"
|
||||||
|
dependencies = [
|
||||||
|
"naga",
|
||||||
|
"wgpu-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-types"
|
name = "wgpu-types"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c"
|
checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"naga-types",
|
||||||
|
"raw-window-handle",
|
||||||
|
"static_assertions",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4760,12 +4740,12 @@ dependencies = [
|
|||||||
"android-activity",
|
"android-activity",
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"calloop",
|
"calloop",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"cursor-icon",
|
"cursor-icon",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -5064,18 +5044,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.56"
|
version = "0.8.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.56"
|
version = "0.8.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -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"]
|
||||||
@@ -13,8 +13,37 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.iris.android.demo"
|
applicationId = "dev.iris.android.demo"
|
||||||
minSdk = 26
|
// 29, not 26: `iris::android::view`'s touch handler dates each
|
||||||
targetSdk = 34
|
// 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
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
+2
-6
@@ -16,12 +16,8 @@ import android.view.inputmethod.InputMethodManager;
|
|||||||
|
|
||||||
public abstract class RustView extends SurfaceView
|
public abstract class RustView extends SurfaceView
|
||||||
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
||||||
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view)
|
// Vendored from android-view bec6c62. The only local change is `protected`,
|
||||||
// with one deliberate change: `protected` rather than package-private, so a
|
// allowing IrisView to forward insets through this native peer.
|
||||||
// subclass in a different package (dev.iris.android.demo.IrisView) can pass
|
|
||||||
// it to the window-insets native call android-view itself has no hook for --
|
|
||||||
// see iris/src/android/insets.rs's doc comment for why that call exists at
|
|
||||||
// all. No other line differs from upstream.
|
|
||||||
protected final long mViewPeer;
|
protected final long mViewPeer;
|
||||||
final InputMethodManager mInputMethodManager;
|
final InputMethodManager mInputMethodManager;
|
||||||
|
|
||||||
File renamed without changes.
File renamed without changes.
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
#
|
#
|
||||||
# Usage: ./run-bench.sh [--apk PATH]
|
# Usage: ./run-bench.sh [--apk PATH]
|
||||||
# Defaults to this checkout's own release APK
|
# Defaults to this checkout's own release APK
|
||||||
# (app/build/outputs/apk/release/app-release.apk) if it exists, else the
|
# (android-project/app/build/outputs/apk/release/app-release.apk) if it
|
||||||
|
# exists, else the
|
||||||
# debug one -- build one first with ./build-apk.sh.
|
# debug one -- build one first with ./build-apk.sh.
|
||||||
set -eu
|
set -eu
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
@@ -20,10 +21,10 @@ while [ $# -gt 0 ]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
if [ -z "$APK" ]; then
|
if [ -z "$APK" ]; then
|
||||||
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
|
if [ -f android-project/app/build/outputs/apk/release/app-release.apk ]; then
|
||||||
APK=app/build/outputs/apk/release/app-release.apk
|
APK=android-project/app/build/outputs/apk/release/app-release.apk
|
||||||
else
|
else
|
||||||
APK=app/build/outputs/apk/debug/app-debug.apk
|
APK=android-project/app/build/outputs/apk/debug/app-debug.apk
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [ ! -f "$APK" ]; then
|
if [ ! -f "$APK" ]; then
|
||||||
@@ -46,14 +47,34 @@ adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/d
|
|||||||
|
|
||||||
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/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
|
# 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
|
# 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
|
# 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
|
# 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.
|
# 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
|
i=0
|
||||||
while [ "$i" -lt 260 ]; do
|
while [ "$i" -lt 260 ]; do
|
||||||
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true)
|
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
|
||||||
if [ -n "$LINE" ]; then
|
if [ -n "$LINE" ]; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -66,4 +87,4 @@ if [ -z "$LINE" ]; then
|
|||||||
fi
|
fi
|
||||||
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
|
# -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.
|
# 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 "iris bench report:"
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,6 @@
|
|||||||
//! P0's iris half (docs/RUST.md's P0 box, docs/AGENTS.md's "The rigs"):
|
use crate::android::bench_jni::PlatformHandle;
|
||||||
//! the same fixture, scroll loop and streaming phase the Compose `bench`
|
use crate::client::transcript_fold::{TranscriptItem, fold_event};
|
||||||
//! build type's `BenchRun.kt`/`BenchFixture.kt` drive, run here against
|
|
||||||
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
|
||||||
//! that measures the renderer rather than the data or the network.
|
|
||||||
//!
|
|
||||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
|
||||||
//! `TranscriptScreen::apply` incremental update on every event) with the
|
|
||||||
//! network half replaced by the checked-in fixture, embedded with
|
|
||||||
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`,
|
|
||||||
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never
|
|
||||||
//! a real transcript (that file's own README). The first 3,200 lines are
|
|
||||||
//! the opening backlog, folded once through
|
|
||||||
//! `client_core::transcript_fold::fold_page` exactly as a real
|
|
||||||
//! `/transcript` page would be (then a full `transcript_ui::build_tree`,
|
|
||||||
//! same as any first load); the remaining ~400 are the streaming tail,
|
|
||||||
//! replayed one at a time through `fold_event` -- the same fold path a
|
|
||||||
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
|
||||||
//! Streaming through `apply` rather than a full rebuild per event is what
|
|
||||||
//! this file exists to measure -- see docs/RUST.md's P0 box for the
|
|
||||||
//! before/after report.
|
|
||||||
|
|
||||||
use crate::bench_jni::PlatformHandle;
|
|
||||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||||
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
|
||||||
use event_model::SeqEvent;
|
use event_model::SeqEvent;
|
||||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
@@ -30,42 +8,16 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
|
|
||||||
/// the opening window; the rest are the streaming tail. Kept in sync with
|
|
||||||
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
|
|
||||||
/// checked-in file, so a mismatch would only mean the two apps' bench
|
|
||||||
/// builds open a different split of it, not a wrong-vs-right answer.
|
|
||||||
const BACKLOG_COUNT: usize = 3200;
|
|
||||||
|
|
||||||
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
|
|
||||||
/// implement the identical four phases -- see that box before changing any
|
|
||||||
/// constant here, since a mismatch would make the two reports stop
|
|
||||||
/// measuring the same thing while still looking like they do.
|
|
||||||
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
||||||
const STREAM_SECONDS: u64 = 20;
|
const STREAM_SECONDS: u64 = 20;
|
||||||
|
|
||||||
/// Kept only so this phase's own label text still reads "scroll: 6 cycles
|
|
||||||
/// (24 swipes, legacy tween)" the way `BenchRun.kt`'s v2 report does --
|
|
||||||
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s own report shows this
|
|
||||||
/// exact line even though the swipe loop it names no longer runs there
|
|
||||||
/// either (the fling phase replaced it); nothing here drives an actual
|
|
||||||
/// swipe with these any more.
|
|
||||||
const LEGACY_CYCLES: usize = 6;
|
const LEGACY_CYCLES: usize = 6;
|
||||||
|
|
||||||
/// Fling phase (v2): a real fling through `List::fling`, not a tween --
|
|
||||||
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a
|
|
||||||
/// tween can never exceed the distance/time it is given while a real
|
|
||||||
/// fling decays from an initial velocity the way a finger flick does.
|
|
||||||
/// 12,000 px/s matches `BenchRun.kt`'s own constant exactly.
|
|
||||||
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
|
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
|
||||||
const FLING_COUNT: usize = 8;
|
const FLING_COUNT: usize = 8;
|
||||||
const FLING_SETTLE_CAP_MS: u64 = 3_000;
|
const FLING_SETTLE_CAP_MS: u64 = 3_000;
|
||||||
const FLING_PAUSE_MS: u64 = 300;
|
const FLING_PAUSE_MS: u64 = 300;
|
||||||
|
|
||||||
/// Type phase (v2): long, multisyllabic words so the composer actually
|
|
||||||
/// wraps and the transcript above it is pushed upward, typed and deleted
|
|
||||||
/// one character per `TYPE_CHAR_MS`. Exactly `BenchRun.TYPE_TEXT` --
|
|
||||||
/// verified 600 characters by `type_text_is_exactly_600_characters` below.
|
|
||||||
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
|
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
|
||||||
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
|
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
|
||||||
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
|
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
|
||||||
@@ -76,66 +28,29 @@ keyboard-adjacent box, which is exactly what a real reader typing a long message
|
|||||||
happening now!!!";
|
happening now!!!";
|
||||||
const TYPE_CHAR_MS: u64 = 50;
|
const TYPE_CHAR_MS: u64 = 50;
|
||||||
|
|
||||||
/// Keyboard phase (v2): five show/hide cycles, a second apart, matching
|
|
||||||
/// `BenchRun.kt`'s `KEYBOARD_CYCLES`/`KEYBOARD_SHOW_WAIT_MS`/
|
|
||||||
/// `KEYBOARD_HIDE_WAIT_MS`.
|
|
||||||
const KEYBOARD_CYCLES: usize = 5;
|
const KEYBOARD_CYCLES: usize = 5;
|
||||||
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
||||||
|
|
||||||
/// One animation step's target cadence -- close enough to 60Hz that a
|
const POLL_MS: u64 = 16;
|
||||||
/// fling/scroll is many small moves rather than one jump, so frames are
|
|
||||||
/// actually rendered along the way, and close enough that a `ctx.update`
|
|
||||||
/// closure's effect (only applied once the next frame callback drains the
|
|
||||||
/// task channel -- `IrisViewPeer::drain_tasks`) is visible again quickly
|
|
||||||
/// when a later step in the same phase needs to read state back.
|
|
||||||
const ANIM_STEP_MS: u64 = 16;
|
|
||||||
|
|
||||||
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
|
||||||
|
|
||||||
pub struct BenchClient {
|
pub struct BenchClient {
|
||||||
ui_state: AndroidUiState,
|
ui_state: AndroidUiState,
|
||||||
content: WeakWidget<WidgetPtr>,
|
content: WeakWidget<WidgetPtr>,
|
||||||
report_display: WeakWidget<TextEdit>,
|
report_display: WeakWidget<TextEdit>,
|
||||||
/// The top button row, in a `WidgetPtr` slot rather than added
|
|
||||||
/// directly (like `content`) so `on_insets_changed` can swap in a
|
|
||||||
/// version padded for the status bar once insets are known -- RUST.md's
|
|
||||||
/// P0 box, "the status-bar inset is not applied," found the row sitting
|
|
||||||
/// directly under it because nothing here read `insets().top` at all.
|
|
||||||
top_bar: WeakWidget<WidgetPtr>,
|
top_bar: WeakWidget<WidgetPtr>,
|
||||||
screen: Option<transcript_ui::TranscriptScreen>,
|
screen: Option<crate::ui::TranscriptScreen>,
|
||||||
items: Vec<TranscriptItem>,
|
items: Vec<TranscriptItem>,
|
||||||
/// The events not yet streamed -- consumed by `start_benchmark`'s own
|
|
||||||
/// clone, kept here only as the source a second run would need (the
|
|
||||||
/// button can be pressed more than once; `running` just stops overlap,
|
|
||||||
/// not repeat).
|
|
||||||
stream_tail: Vec<SeqEvent>,
|
stream_tail: Vec<SeqEvent>,
|
||||||
platform: Option<Arc<PlatformHandle>>,
|
platform: Option<Arc<PlatformHandle>>,
|
||||||
last_report: Option<String>,
|
last_report: Option<String>,
|
||||||
running: bool,
|
running: bool,
|
||||||
/// The keyboard phase's own confirmation channel -- updated from
|
|
||||||
/// `on_insets_changed` (the platform's own answer for whether the IME
|
|
||||||
/// is actually visible, per `WindowInsets::ime_bottom`), read from the
|
|
||||||
/// benchmark's spawned task via the shared `Arc<Mutex<_>>` rather than
|
|
||||||
/// `ctx.update`, since neither side needs the widget tree for this.
|
|
||||||
ime_state: Arc<Mutex<ImeState>>,
|
ime_state: Arc<Mutex<ImeState>>,
|
||||||
/// Edge-triggers the keyboard diagnostics capture below -- set on the
|
|
||||||
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
|
|
||||||
/// first where it is not, so opening the keyboard fires this once
|
|
||||||
/// rather than on every insets update while it stays open (a rotation
|
|
||||||
/// or a status-bar change with the keyboard already up would otherwise
|
|
||||||
/// re-fire it).
|
|
||||||
keyboard_was_visible: bool,
|
keyboard_was_visible: bool,
|
||||||
/// The status-bar inset `top_bar` was last padded by -- see
|
|
||||||
/// `on_insets_changed`'s own comment for why this guards the rebuild.
|
|
||||||
last_top_pad: f32,
|
last_top_pad: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
|
|
||||||
/// count real 0->visible / visible->0 transitions `on_insets_changed`
|
|
||||||
/// observed, not merely "a show/hide was requested" -- UI_RULES.md: never
|
|
||||||
/// present an inferred value as a measured one. `run_keyboard_phase` reads
|
|
||||||
/// the counters before and after asking for a toggle and calls it
|
|
||||||
/// confirmed only if the count moved.
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct ImeState {
|
struct ImeState {
|
||||||
visible: bool,
|
visible: bool,
|
||||||
@@ -152,44 +67,15 @@ impl HasAndroidUiState for BenchClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses the fixture once: `serde_json::Value`s for the backlog
|
|
||||||
/// (`fold_page` takes a page of raw wire JSON, same as a real
|
|
||||||
/// `/transcript` response) and folded `SeqEvent`s for the tail (`fold_event`
|
|
||||||
/// takes one live wire event at a time, same as a real SSE frame).
|
|
||||||
fn parse_fixture() -> (Vec<serde_json::Value>, Vec<SeqEvent>) {
|
|
||||||
let lines: Vec<&str> = FIXTURE_JSONL
|
|
||||||
.lines()
|
|
||||||
.filter(|line| !line.trim().is_empty())
|
|
||||||
.collect();
|
|
||||||
let mut backlog = Vec::with_capacity(BACKLOG_COUNT.min(lines.len()));
|
|
||||||
let mut stream_tail = Vec::new();
|
|
||||||
for (i, line) in lines.iter().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 {
|
|
||||||
let event: SeqEvent = serde_json::from_value(value)
|
|
||||||
.expect("bench fixture event matches event-model's SeqEvent");
|
|
||||||
stream_tail.push(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(backlog, stream_tail)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||||
wtext(message.to_string())
|
wtext(message.to_string())
|
||||||
.color(Color::WHITE)
|
.color(PaintId::WHITE)
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.pad(16)
|
.pad(16)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any()
|
.any()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `getrusage(RUSAGE_SELF)`'s user+system time, in ms -- `None` only if
|
|
||||||
/// the syscall itself fails, which UI_RULES.md's "never present an
|
|
||||||
/// inferred value as a measured one" says to keep apart from a real (and
|
|
||||||
/// here, impossible) zero.
|
|
||||||
fn process_cpu_ms() -> Option<u64> {
|
fn process_cpu_ms() -> Option<u64> {
|
||||||
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
|
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
|
||||||
// initialises on success; on failure it is never read.
|
// initialises on success; on failure it is never read.
|
||||||
@@ -204,9 +90,6 @@ fn process_cpu_ms() -> Option<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `VmHWM` from `/proc/self/status` -- the process's peak RSS since it
|
|
||||||
/// started, in kB. Same source `BenchRun.kt`'s `peakRssLine` reads, so the
|
|
||||||
/// two reports' numbers mean the same thing.
|
|
||||||
fn peak_rss_kb() -> Option<u64> {
|
fn peak_rss_kb() -> Option<u64> {
|
||||||
std::fs::read_to_string("/proc/self/status")
|
std::fs::read_to_string("/proc/self/status")
|
||||||
.ok()?
|
.ok()?
|
||||||
@@ -221,8 +104,9 @@ fn battery_line(samples: &[i32]) -> String {
|
|||||||
return " battery current: unavailable on this device".to_string();
|
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 mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
||||||
let min = samples.iter().min().unwrap();
|
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
|
||||||
let max = samples.iter().max().unwrap();
|
unreachable!("samples is non-empty, checked above");
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
||||||
samples.len()
|
samples.len()
|
||||||
@@ -240,7 +124,7 @@ impl AndroidAppState for BenchClient {
|
|||||||
.text_align(Align::LEFT)
|
.text_align(Align::LEFT)
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.size(14)
|
.size(14)
|
||||||
.color(Color::WHITE)
|
.color(PaintId::WHITE)
|
||||||
.attr::<Selectable>(())
|
.attr::<Selectable>(())
|
||||||
.label("Benchmark report")
|
.label("Benchmark report")
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
@@ -250,22 +134,20 @@ impl AndroidAppState for BenchClient {
|
|||||||
top_bar(rsc).set(controls);
|
top_bar(rsc).set(controls);
|
||||||
let tree = (
|
let tree = (
|
||||||
top_bar,
|
top_bar,
|
||||||
content.height(rest(2)),
|
report_display
|
||||||
report_display.height(rest(1)).pad(dp(8)),
|
.pad(dp(8))
|
||||||
|
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
|
||||||
|
content.height(rest(1)),
|
||||||
)
|
)
|
||||||
.span(Dir::DOWN)
|
.span(Dir::DOWN)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any();
|
.any();
|
||||||
ui_state.set_root(tree);
|
ui_state.set_root(rsc, tree);
|
||||||
|
|
||||||
// Startup log line (RUST.md's P0 box, "log once at startup ... the
|
|
||||||
// number of font families found, the default family resolved"):
|
|
||||||
// what font discovery actually found on this device, before
|
|
||||||
// anything is drawn.
|
|
||||||
let font = rsc.ui.text.font_diagnostics();
|
let font = rsc.ui.text.font_diagnostics();
|
||||||
log::info!(
|
log::info!(
|
||||||
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
||||||
bold={:?} italic={:?} mono={:?}",
|
bold={:?} italic={:?} mono={:?}, icons={:?}",
|
||||||
font.families_found,
|
font.families_found,
|
||||||
font.default_family,
|
font.default_family,
|
||||||
font.default_mono_family,
|
font.default_mono_family,
|
||||||
@@ -273,6 +155,7 @@ impl AndroidAppState for BenchClient {
|
|||||||
font.bold_resolved,
|
font.bold_resolved,
|
||||||
font.italic_resolved,
|
font.italic_resolved,
|
||||||
font.mono_resolved,
|
font.mono_resolved,
|
||||||
|
font.icon_family,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut client = Self {
|
let mut client = Self {
|
||||||
@@ -291,12 +174,12 @@ impl AndroidAppState for BenchClient {
|
|||||||
last_top_pad: 0.0,
|
last_top_pad: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (backlog, stream_tail) = parse_fixture();
|
match crate::ui::fixture::build_screen(rsc) {
|
||||||
client.stream_tail = stream_tail;
|
Ok((opened, tree)) => {
|
||||||
match fold_page(&backlog) {
|
client.items = opened.items;
|
||||||
Ok(items) => {
|
client.stream_tail = opened.stream_tail;
|
||||||
client.items = items;
|
(client.content)(rsc).set(tree);
|
||||||
client.rebuild_transcript(rsc);
|
client.screen = Some(opened.screen);
|
||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
||||||
@@ -313,47 +196,6 @@ impl AndroidAppState for BenchClient {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pads the top button row by the status-bar inset -- see `top_bar`'s
|
|
||||||
/// field comment. Rebuilds the row rather than mutating a stored
|
|
||||||
/// `Padding` in place, since nothing here holds a handle to one --
|
|
||||||
/// but **only when `insets.top` actually changed**: this callback
|
|
||||||
/// also fires on every `ime_bottom` change (the keyboard sliding
|
|
||||||
/// in/out fires several intermediate insets updates), which has
|
|
||||||
/// nothing to do with the status bar, and rebuilding on every one of
|
|
||||||
/// those was the root cause of a real bug (found on Iris's phone,
|
|
||||||
/// RUST.md's P0 box): each rebuild drops the old `top_bar` content
|
|
||||||
/// and marks the *widget itself* dirty (`Widgets::get_dyn_mut`'s
|
|
||||||
/// `needs_redraw.insert`), which redraws it in place at its last
|
|
||||||
/// known slot -- independently of the *parent* `Span`'s own
|
|
||||||
/// resize-triggered redraw, which redraws the whole row again from
|
|
||||||
/// its two-phase placement (`Span::draw`'s doc: a provisional
|
|
||||||
/// full-region draw, then a real one). A `.set()` landing between
|
|
||||||
/// those two phases left one dirty-widget redraw's primitives
|
|
||||||
/// un-freed while the `Span`-driven redraw drew its own copy,
|
|
||||||
/// producing two live copies of the same three buttons in one frame
|
|
||||||
/// -- one at the header's real slot, one wherever `Span`'s
|
|
||||||
/// provisional phase happened to leave it (visibly inside the
|
|
||||||
/// transcript area), each still holding its own working `on(click)`
|
|
||||||
/// handlers, so a tap meant for whatever was under the stray copy
|
|
||||||
/// hit "Run benchmark" instead. Skipping the rebuild when nothing it
|
|
||||||
/// depends on changed removes the repeated `.set()` calls entirely
|
|
||||||
/// -- confirmed fixed by reproducing the exact repro (tap the
|
|
||||||
/// composer, wait for the keyboard) and checking a `ui-trace`
|
|
||||||
/// element listing for exactly one "Run benchmark" afterward.
|
|
||||||
///
|
|
||||||
/// Also two things downstream of the same `ime_bottom` transition:
|
|
||||||
/// **the keyboard phase's own confirmation signal** (`ime_state`'s
|
|
||||||
/// doc -- the platform's own answer for whether the IME actually
|
|
||||||
/// opened or closed, rather than assumed from having called
|
|
||||||
/// `show_ime`/`hide_ime`), and **the trigger for the keyboard
|
|
||||||
/// diagnostics capture** (RUST.md's P0 box): the IME resizing the
|
|
||||||
/// surface is exactly the case a previous commit found wiped text,
|
|
||||||
/// and Iris needs a way to get a report off the phone even if that
|
|
||||||
/// (or some other keyboard-triggered regression) is still happening
|
|
||||||
/// on the build she is holding -- `capture_keyboard_diagnostics`
|
|
||||||
/// below fires ~500ms after the keyboard becomes visible, once per
|
|
||||||
/// keyboard opening, and shows its report in a plain overlay view
|
|
||||||
/// that draws independently of whatever iris itself is doing.
|
|
||||||
fn on_insets_changed(
|
fn on_insets_changed(
|
||||||
&mut self,
|
&mut self,
|
||||||
rsc: &mut AndroidRsc<Self>,
|
rsc: &mut AndroidRsc<Self>,
|
||||||
@@ -365,19 +207,18 @@ impl AndroidAppState for BenchClient {
|
|||||||
(self.top_bar)(rsc).set(controls);
|
(self.top_bar)(rsc).set(controls);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The composer bar sits directly on whichever of the IME or the
|
|
||||||
// navigation bar is currently the bottom of usable space -- see
|
|
||||||
// `transcript_ui::composer::Composer::set_bottom_inset`'s doc.
|
|
||||||
// `ime_bottom` already exceeds the plain nav-bar inset whenever the
|
|
||||||
// keyboard covers it, so the larger of the two is always the right
|
|
||||||
// answer without needing to know which is currently showing.
|
|
||||||
if let Some(screen) = &self.screen {
|
if let Some(screen) = &self.screen {
|
||||||
screen
|
screen
|
||||||
.composer
|
.composer
|
||||||
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
let ime_visible = insets.ime_bottom > 0.0;
|
// 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();
|
let mut ime = self.ime_state.lock().unwrap();
|
||||||
if ime_visible && !ime.visible {
|
if ime_visible && !ime.visible {
|
||||||
@@ -405,46 +246,50 @@ impl AndroidAppState for BenchClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long to wait after the keyboard becomes visible before capturing
|
|
||||||
/// diagnostics -- long enough that the resize, the reported wipe (if it is
|
|
||||||
/// still happening) and a couple of frames have all had time to land, per
|
|
||||||
/// AGENTS.md's "so that operations that finish in milliseconds have states
|
|
||||||
/// on the way that nothing can observe" reasoning applied the other way:
|
|
||||||
/// this wants to observe the state *after* the transition settles, not
|
|
||||||
/// mid-flight.
|
|
||||||
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
||||||
|
|
||||||
type Rsc = AndroidRsc<BenchClient>;
|
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
|
/// 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
|
/// 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
|
/// (`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.
|
/// instead of a hole in the background the buttons happen to float in.
|
||||||
const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
|
const HEADER_SURFACE: Srgba8 = Srgba8::new(28, 28, 34, 255);
|
||||||
|
|
||||||
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
|
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
|
||||||
/// `on_insets_changed` has run once) -- folded in here, rather than
|
/// `on_insets_changed` has run once) -- folded in here, rather than
|
||||||
/// exposing the unadded builder for a caller to `.pad()` itself, because
|
/// 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
|
/// naming that builder's type at each call site is more machinery than a
|
||||||
/// top-of-screen padding number is worth.
|
/// top-of-screen padding number is worth.
|
||||||
///
|
const HEADER_TEXT: f32 = 18.0;
|
||||||
/// **Backed by an opaque rect the full size of the row, not just the three
|
|
||||||
/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on
|
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
|
||||||
/// build a9232ac): "the header buttons have nothing behind them and
|
|
||||||
/// overlap the transcript text" -- before this, only each button's own
|
|
||||||
/// `rect(...)` painted anything, so the gaps between and around them (and
|
|
||||||
/// the status-bar strip above them) showed whatever was one layer back
|
|
||||||
/// (`CLEAR_COLOR`, black), and the row's true height was three
|
|
||||||
/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the
|
|
||||||
/// density-correct size the transcript below was already using post-P0 --
|
|
||||||
/// exactly what reads as "overlap" once the two disagree. Fixed two ways
|
|
||||||
/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this
|
|
||||||
/// function), and every size below moved from a bare number (physical
|
|
||||||
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
|
|
||||||
/// so the row's reserved height in the outer `Span::DOWN`
|
|
||||||
/// (`AndroidAppState::new`) matches what is actually painted.
|
|
||||||
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||||
let run_rect = rect(Color::rgb(40, 70, 40))
|
let run_rect = rect(Srgba8::rgb(40, 70, 40))
|
||||||
.on(
|
.on(
|
||||||
CursorSense::click(),
|
CursorSense::click(),
|
||||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
@@ -454,29 +299,33 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
|||||||
.label("Run benchmark");
|
.label("Run benchmark");
|
||||||
let run = (
|
let run = (
|
||||||
run_rect,
|
run_rect,
|
||||||
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
wtext("Run benchmark")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
)
|
)
|
||||||
.stack()
|
.stack()
|
||||||
.pad(dp(8))
|
.pad(dp(8))
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
let copy_rect = rect(Color::rgb(50, 50, 60))
|
let copy_rect = rect(Srgba8::rgb(50, 50, 60))
|
||||||
.on(
|
.on(
|
||||||
CursorSense::click(),
|
CursorSense::click(),
|
||||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
ctx.state.copy_report();
|
ctx.state.copy_report(rsc);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.label("Copy report");
|
.label("Copy report");
|
||||||
let copy = (
|
let copy = (
|
||||||
copy_rect,
|
copy_rect,
|
||||||
wtext("Copy report").size(18).text_align(Align::CENTER),
|
wtext("Copy report")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
)
|
)
|
||||||
.stack()
|
.stack()
|
||||||
.pad(dp(8))
|
.pad(dp(8))
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
let diag_rect = rect(Color::rgb(60, 45, 70))
|
let diag_rect = rect(Srgba8::rgb(60, 45, 70))
|
||||||
.on(
|
.on(
|
||||||
CursorSense::click(),
|
CursorSense::click(),
|
||||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
@@ -486,17 +335,50 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
|||||||
.label("Diagnostics");
|
.label("Diagnostics");
|
||||||
let diagnostics = (
|
let diagnostics = (
|
||||||
diag_rect,
|
diag_rect,
|
||||||
wtext("Diagnostics").size(18).text_align(Align::CENTER),
|
wtext("Diagnostics")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
)
|
)
|
||||||
.stack()
|
.stack()
|
||||||
.pad(dp(8))
|
.pad(dp(8))
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).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)
|
(rect(HEADER_SURFACE), buttons)
|
||||||
.stack()
|
.stack()
|
||||||
.height(dp(56))
|
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
|
||||||
.pad(Padding::top(top_pad))
|
.pad(Padding::top(top_pad))
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any()
|
.any()
|
||||||
@@ -510,82 +392,87 @@ impl BenchClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
||||||
let rows = group_tool_runs(&self.items);
|
let (screen, tree) = crate::ui::build_tree(rsc, crate::ui::fixture::rows(&self.items));
|
||||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
|
||||||
(self.content)(rsc).set(tree);
|
(self.content)(rsc).set(tree);
|
||||||
self.screen = Some(screen);
|
self.screen = Some(screen);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RUST.md's P0 box: "a named `Diagnostics` control ... with 'copy this
|
|
||||||
/// and send it to Iris'." Fills `report_display` (the same TextEdit the
|
|
||||||
/// benchmark report uses) rather than a separate widget, so the
|
|
||||||
/// existing "Copy report" button and clipboard path work on whichever
|
|
||||||
/// text is currently shown -- `last_report` is what `copy_report` reads,
|
|
||||||
/// so it's set here too rather than adding a second copy path.
|
|
||||||
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
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 font = rsc.ui.text.font_diagnostics();
|
||||||
let frame_report = match self.android_state().frame_report.report() {
|
let frame_report = match self.android_state().frame_report.report() {
|
||||||
Some(stats) => format!("{stats}"),
|
Some(stats) => format!("{stats}"),
|
||||||
None => "no frames recorded yet".to_string(),
|
None => "no frames recorded yet".to_string(),
|
||||||
};
|
};
|
||||||
let report = match &self.android_state().renderer {
|
let renderer = match &self.android_state().renderer {
|
||||||
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||||
};
|
};
|
||||||
self.report_display.edit(rsc).set(&report);
|
// Insets must be visible without adb so a missing callback can be
|
||||||
self.last_report = Some(report);
|
// 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()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
|
||||||
/// doc comment. Reuses `show_diagnostics`'s exact report (so it is the
|
|
||||||
/// same text the on-screen `Diagnostics` button produces, plus the
|
|
||||||
/// per-frame log `FrameReport` already keeps around the resize --
|
|
||||||
/// `frame_report.report()` above covers "the frames around the
|
|
||||||
/// resize" without a second accounting mechanism), then does three
|
|
||||||
/// things the button does not: logs it (so a `logcat` pull gets it
|
|
||||||
/// even if nothing on screen does), copies it to the clipboard
|
|
||||||
/// unprompted, and shows it in the shell's plain overlay view, which
|
|
||||||
/// draws independently of iris's own renderer -- the whole point,
|
|
||||||
/// since the renderer is exactly what might be in the wiped state
|
|
||||||
/// this exists to report on.
|
|
||||||
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||||
self.show_diagnostics(rsc);
|
let report = self.diagnostics_text(rsc);
|
||||||
let Some(report) = self.last_report.clone() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
log::info!("iris keyboard diagnostics:\n{report}");
|
log::info!("iris keyboard diagnostics:\n{report}");
|
||||||
let Some(platform) = &self.platform else {
|
|
||||||
log::info!("iris keyboard diagnostics: no platform handle, can't reach the shell");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if platform.copy_to_clipboard("iris keyboard diagnostics", &report) {
|
|
||||||
log::info!("iris keyboard diagnostics: copied to clipboard");
|
|
||||||
} else {
|
|
||||||
log::info!("iris keyboard diagnostics: clipboard copy failed");
|
|
||||||
}
|
|
||||||
platform.show_diagnostics_overlay(&report);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy_report(&mut self) {
|
fn copy_report(&mut self, rsc: &mut Rsc) {
|
||||||
let Some(report) = &self.last_report else {
|
|
||||||
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(platform) = &self.platform else {
|
let Some(platform) = &self.platform else {
|
||||||
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if platform.copy_to_clipboard("iris bench report", report) {
|
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");
|
log::info!("iris bench report: copied to clipboard");
|
||||||
} else {
|
} else {
|
||||||
log::info!("iris bench report: clipboard copy failed");
|
log::info!("iris bench report: clipboard copy failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1),
|
|
||||||
/// then type, then keyboard, then the report -- run in-process for the
|
|
||||||
/// same reason `BenchRun.kt`'s own doc gives (no usable system tracing
|
|
||||||
/// on a real phone, no agent that can drive one).
|
|
||||||
fn start_benchmark(&mut self, rsc: &mut Rsc) {
|
fn start_benchmark(&mut self, rsc: &mut Rsc) {
|
||||||
if self.running {
|
if self.running {
|
||||||
log::info!("iris bench report: already running");
|
log::info!("iris bench report: already running");
|
||||||
@@ -599,11 +486,13 @@ impl BenchClient {
|
|||||||
let platform = self.platform.clone();
|
let platform = self.platform.clone();
|
||||||
let stream_tail = self.stream_tail.clone();
|
let stream_tail = self.stream_tail.clone();
|
||||||
let ime_state = self.ime_state.clone();
|
let ime_state = self.ime_state.clone();
|
||||||
let refresh_hz = platform
|
let platform_hz = platform.as_ref().and_then(|p| p.refresh_rate_hz());
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.refresh_rate_hz())
|
|
||||||
.unwrap_or(60.0);
|
|
||||||
let cpu_start = process_cpu_ms();
|
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();
|
let run_started_at = Instant::now();
|
||||||
|
|
||||||
rsc.spawn_task(async move |mut ctx| {
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
@@ -654,6 +543,26 @@ impl BenchClient {
|
|||||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
state.running = false;
|
state.running = false;
|
||||||
let now = Instant::now();
|
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
|
let phase_lines: String = state
|
||||||
.android_state()
|
.android_state()
|
||||||
.frame_report
|
.frame_report
|
||||||
@@ -671,9 +580,11 @@ impl BenchClient {
|
|||||||
let (late, late_pct) =
|
let (late, late_pct) =
|
||||||
state.android_state().frame_report.late_at_hz(refresh_hz);
|
state.android_state().frame_report.late_at_hz(refresh_hz);
|
||||||
format!(
|
format!(
|
||||||
"frames:\n {} frames over {:.1}s at {:.0}Hz ({:.1}ms budget)\n \
|
"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 \
|
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
|
||||||
p99 {:.1}ms\n worst {:.1}ms\n cpu_p50 {:.1}ms gpu_wait_p50 {:.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,
|
stats.total_frames,
|
||||||
total_seconds,
|
total_seconds,
|
||||||
refresh_hz,
|
refresh_hz,
|
||||||
@@ -683,6 +594,7 @@ impl BenchClient {
|
|||||||
stats.p99.as_secs_f64() * 1000.0,
|
stats.p99.as_secs_f64() * 1000.0,
|
||||||
stats.worst.as_secs_f64() * 1000.0,
|
stats.worst.as_secs_f64() * 1000.0,
|
||||||
stats.cpu_p50.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,
|
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -701,9 +613,11 @@ impl BenchClient {
|
|||||||
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
||||||
TYPE_TEXT.chars().count()
|
TYPE_TEXT.chars().count()
|
||||||
);
|
);
|
||||||
|
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
|
||||||
let report = format!(
|
let report = format!(
|
||||||
"iris bench report\n{per_phase}{frames_block}\n\nbench:\n{fling_line}\n\
|
"iris bench report\n{traced}\n{per_phase}{frames_block}\n\nbench:\n\
|
||||||
{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n{rss_line}\n{battery}"
|
{fling_line}\n{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n\
|
||||||
|
{rss_line}\n{battery}"
|
||||||
);
|
);
|
||||||
log::info!("iris bench report: {report}");
|
log::info!("iris bench report: {report}");
|
||||||
state.report_display.edit(rsc).set(&report);
|
state.report_display.edit(rsc).set(&report);
|
||||||
@@ -728,7 +642,7 @@ impl BenchClient {
|
|||||||
/// drained everything queued before this call existed. Cost a real hang
|
/// drained everything queued before this call existed. Cost a real hang
|
||||||
/// in this file's first version of the fling phase: every loop iteration
|
/// in this file's first version of the fling phase: every loop iteration
|
||||||
/// after the first sat forever with nothing scheduled to drain it.
|
/// after the first sat forever with nothing scheduled to drain it.
|
||||||
/// Polls rather than assuming one `ANIM_STEP_MS` sleep is enough, since a
|
/// Polls rather than assuming one `POLL_MS` sleep is enough, since a
|
||||||
/// slow device's frame callback can lag further than that.
|
/// slow device's frame callback can lag further than that.
|
||||||
async fn read_from_state<T, F>(
|
async fn read_from_state<T, F>(
|
||||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
@@ -748,19 +662,10 @@ where
|
|||||||
if let Ok(value) = rx.try_recv() {
|
if let Ok(value) = rx.try_recv() {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
|
|
||||||
/// from it (toward older messages) through `List::fling`, then
|
|
||||||
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
|
|
||||||
/// convention (`List::scroll`'s own doc: positive moves *later* content
|
|
||||||
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
|
|
||||||
/// since `TranscriptList`'s `LazyColumn` and this list define "positive"
|
|
||||||
/// the other way around; the two apps' *travel* is still directly
|
|
||||||
/// comparable because both report it as a row index + pixel offset, not a
|
|
||||||
/// signed distance.
|
|
||||||
async fn run_fling_phase(
|
async fn run_fling_phase(
|
||||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
redraw: &Arc<dyn RequestRedraw>,
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
@@ -776,13 +681,14 @@ async fn run_fling_phase(
|
|||||||
redraw.request_redraw();
|
redraw.request_redraw();
|
||||||
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
|
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
|
||||||
// `anchor = None` into a real slot before `start` is read.
|
// `anchor = None` into a real slot before `start` is read.
|
||||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS * 2)).await;
|
tokio::time::sleep(Duration::from_millis(POLL_MS * 2)).await;
|
||||||
let start = read_anchor_position(ctx, redraw).await;
|
let start = read_anchor_position(ctx, redraw).await;
|
||||||
|
|
||||||
for _ in 0..FLING_COUNT {
|
for _ in 0..FLING_COUNT {
|
||||||
ctx.update(|state: &mut BenchClient, rsc| {
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
if let Some(screen) = &state.screen {
|
if let Some(screen) = &state.screen {
|
||||||
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
||||||
|
animate_scroll(screen.list, rsc);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
redraw.request_redraw();
|
redraw.request_redraw();
|
||||||
@@ -794,7 +700,8 @@ async fn run_fling_phase(
|
|||||||
for _ in 0..FLING_COUNT {
|
for _ in 0..FLING_COUNT {
|
||||||
ctx.update(|state: &mut BenchClient, rsc| {
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
if let Some(screen) = &state.screen {
|
if let Some(screen) = &state.screen {
|
||||||
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
||||||
|
animate_scroll(screen.list, rsc);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
redraw.request_redraw();
|
redraw.request_redraw();
|
||||||
@@ -803,7 +710,7 @@ async fn run_fling_phase(
|
|||||||
}
|
}
|
||||||
let end = read_anchor_position(ctx, redraw).await;
|
let end = read_anchor_position(ctx, redraw).await;
|
||||||
|
|
||||||
format!("start={start} outward={outward} end={end}")
|
format!("start={start} outward={outward} end={end} ticked=frame-loop")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_anchor_position(
|
async fn read_anchor_position(
|
||||||
@@ -817,11 +724,11 @@ async fn read_anchor_position(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ticks the fling forward in ~60Hz steps (the same shape
|
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
|
||||||
/// `run_stream_phase`'s per-event loop and the old `animate_scroll` used)
|
let id = scroll.id();
|
||||||
/// until it settles or `FLING_SETTLE_CAP_MS` passes -- belt-and-suspenders
|
rsc.ui_mut().animate(id);
|
||||||
/// the same way `BenchRun.kt`'s own `waitForSettle` is, since a fling's
|
}
|
||||||
/// own spline-decided `duration()` already caps how long it can run.
|
|
||||||
async fn wait_for_fling_settle(
|
async fn wait_for_fling_settle(
|
||||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
redraw: &Arc<dyn RequestRedraw>,
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
@@ -830,22 +737,17 @@ async fn wait_for_fling_settle(
|
|||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
while started.elapsed() < cap {
|
while started.elapsed() < cap {
|
||||||
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||||
Some(screen) => (screen.list)(rsc).tick_fling(Instant::now()),
|
Some(screen) => (screen.list)(rsc).is_scrolling(),
|
||||||
None => false,
|
None => false,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
if !still_scrolling {
|
if !still_scrolling {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 2, unchanged from v1: pinned to the newest end before streaming
|
|
||||||
/// starts (matching `stream-bench.sh`'s "Jump to latest" tap), then
|
|
||||||
/// `STREAM_EVENTS_PER_SEC * STREAM_SECONDS` fixture events replayed
|
|
||||||
/// through the real `fold_event`/`TranscriptScreen::apply` path. Returns
|
|
||||||
/// `(sent, total)`.
|
|
||||||
async fn run_stream_phase(
|
async fn run_stream_phase(
|
||||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
redraw: &Arc<dyn RequestRedraw>,
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
@@ -876,17 +778,10 @@ async fn run_stream_phase(
|
|||||||
sent += 1;
|
sent += 1;
|
||||||
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||||
}
|
}
|
||||||
// Lets the last few deltas land and draw before the next phase starts
|
|
||||||
// -- `BenchRun.kt`'s own closing delay.
|
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
(sent, total)
|
(sent, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 3: focuses the real composer, shows the keyboard, then types
|
|
||||||
/// `TYPE_TEXT` one character at a time through the composer `TextEdit`'s
|
|
||||||
/// real edit path (`set`, the same call a real keystroke's `onValueChange`
|
|
||||||
/// makes -- `Composer::build_composer`'s `field`), and deletes it the same
|
|
||||||
/// way.
|
|
||||||
async fn run_type_phase(
|
async fn run_type_phase(
|
||||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
redraw: &Arc<dyn RequestRedraw>,
|
redraw: &Arc<dyn RequestRedraw>,
|
||||||
@@ -905,9 +800,6 @@ async fn run_type_phase(
|
|||||||
if let Some(p) = platform {
|
if let Some(p) = platform {
|
||||||
p.show_ime();
|
p.show_ime();
|
||||||
}
|
}
|
||||||
// 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 -- `BenchRun.kt`'s own delay.
|
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
let mut typed = String::new();
|
let mut typed = String::new();
|
||||||
@@ -936,13 +828,6 @@ async fn run_type_phase(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 4: `KEYBOARD_CYCLES` show/hide cycles through the shell's own
|
|
||||||
/// `InputMethodManager` (`bench_jni.rs`'s `show_ime`/`hide_ime`), each
|
|
||||||
/// confirmed by `on_insets_changed`'s real `ime_bottom` transition rather
|
|
||||||
/// than assumed from the JNI call having returned -- `ImeState`'s doc.
|
|
||||||
/// "keyboard: could not be shown" if the platform never confirms it even
|
|
||||||
/// once, per UI_RULES.md ("design the unknown/failed state before the
|
|
||||||
/// answer's").
|
|
||||||
async fn run_keyboard_phase(
|
async fn run_keyboard_phase(
|
||||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
platform: &Option<Arc<PlatformHandle>>,
|
platform: &Option<Arc<PlatformHandle>>,
|
||||||
@@ -989,10 +874,6 @@ async fn run_keyboard_phase(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::TYPE_TEXT;
|
use super::TYPE_TEXT;
|
||||||
|
|
||||||
/// `BenchRun.kt`'s own `TYPE_TEXT` is verified `.length == 600`; this
|
|
||||||
/// is the same string, so it has to match exactly or the two apps'
|
|
||||||
/// type phases stop typing the same content -- RUST.md's "Benchmark
|
|
||||||
/// v2" spec is one shared string for both.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn type_text_is_exactly_600_characters() {
|
fn type_text_is_exactly_600_characters() {
|
||||||
assert_eq!(TYPE_TEXT.chars().count(), 600);
|
assert_eq!(TYPE_TEXT.chars().count(), 600);
|
||||||
@@ -1,24 +1,3 @@
|
|||||||
//! JNI calls the `bench` feature needs that go through the shell's own
|
|
||||||
//! Java side rather than anything `iris`/`android-view` already wraps:
|
|
||||||
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
|
|
||||||
//! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
|
|
||||||
//! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
|
|
||||||
//! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
|
|
||||||
//! report's real late-frame budget and `InputMethodManager.
|
|
||||||
//! showSoftInput`/`hideSoftInputFromWindow` for the keyboard phase. None
|
|
||||||
//! of these are part of `android_view::context`'s own `Context`/
|
|
||||||
//! `Resources` wrappers (that file's own `// TODO: more methods?`), so
|
|
||||||
//! this calls them directly rather than growing that crate's wrapper for
|
|
||||||
//! calls this crate alone needs.
|
|
||||||
//!
|
|
||||||
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
|
|
||||||
//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach
|
|
||||||
//! whichever thread calls it -- the battery sampler runs on a background
|
|
||||||
//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls
|
|
||||||
//! run on. `JavaVM::attach_current_thread` is safe to call from a thread
|
|
||||||
//! already attached (the `jni` crate detects it and does not double
|
|
||||||
//! attach), so no caller here needs to know or care which thread it is.
|
|
||||||
|
|
||||||
use android_view::jni::{
|
use android_view::jni::{
|
||||||
JNIEnv, JavaVM,
|
JNIEnv, JavaVM,
|
||||||
objects::{GlobalRef, JObject, JValue},
|
objects::{GlobalRef, JObject, JValue},
|
||||||
@@ -69,13 +48,6 @@ impl PlatformHandle {
|
|||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None`
|
|
||||||
/// on any JNI failure, on a device with no `BatteryManager` service,
|
|
||||||
/// or when the platform itself answers "not supported" -- `0` or
|
|
||||||
/// `Integer.MIN_VALUE` are both documented SDK answers for that, and
|
|
||||||
/// both would read as a real (and wrong) measurement if folded into an
|
|
||||||
/// average rather than named apart. UI_RULES.md: never present an
|
|
||||||
/// inferred value as a measured one.
|
|
||||||
pub fn battery_current_ua(&self) -> Option<i32> {
|
pub fn battery_current_ua(&self) -> Option<i32> {
|
||||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
let env: &mut JNIEnv = &mut guard;
|
let env: &mut JNIEnv = &mut guard;
|
||||||
@@ -135,14 +107,6 @@ impl PlatformHandle {
|
|||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The display's own refresh rate in Hz (`View::getDisplay()` ->
|
|
||||||
/// `Display::getRefreshRate()`), for RUST.md's "Benchmark v2": late
|
|
||||||
/// frames are judged against *this* device's real budget, not an
|
|
||||||
/// assumed 60Hz -- a 90Hz or 120Hz phone would otherwise call frames
|
|
||||||
/// "late" that met their own faster deadline. `None` if the view is
|
|
||||||
/// not yet attached to a window (`getDisplay` returns `null`) or the
|
|
||||||
/// platform reports a non-positive rate, which is not a real answer
|
|
||||||
/// either.
|
|
||||||
pub fn refresh_rate_hz(&self) -> Option<f32> {
|
pub fn refresh_rate_hz(&self) -> Option<f32> {
|
||||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
let env: &mut JNIEnv = &mut guard;
|
let env: &mut JNIEnv = &mut guard;
|
||||||
@@ -167,20 +131,10 @@ impl PlatformHandle {
|
|||||||
if rate > 0.0 { Some(rate) } else { None }
|
if rate > 0.0 { Some(rate) } else { None }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `InputMethodManager.showSoftInput(view, 0)` -- the keyboard phase's
|
|
||||||
/// own show, called directly rather than through the focus-driven
|
|
||||||
/// `pending_show_keyboard` path `android/view.rs` uses for a real tap,
|
|
||||||
/// since RUST.md's "Benchmark v2" spec asks for this "through the
|
|
||||||
/// shell's InputMethodManager" independent of focus state. `true` only
|
|
||||||
/// if the platform itself reports the request succeeded -- whether the
|
|
||||||
/// IME actually became visible is confirmed separately, from
|
|
||||||
/// `on_insets_changed`, per UI_RULES.md ("never present an inferred
|
|
||||||
/// value as a measured one").
|
|
||||||
pub fn show_ime(&self) -> bool {
|
pub fn show_ime(&self) -> bool {
|
||||||
self.try_toggle_ime(true).unwrap_or(false)
|
self.try_toggle_ime(true).unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `InputMethodManager.hideSoftInputFromWindow(windowToken, 0)`.
|
|
||||||
pub fn hide_ime(&self) -> bool {
|
pub fn hide_ime(&self) -> bool {
|
||||||
self.try_toggle_ime(false).unwrap_or(false)
|
self.try_toggle_ime(false).unwrap_or(false)
|
||||||
}
|
}
|
||||||
@@ -222,31 +176,4 @@ impl PlatformHandle {
|
|||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows `report` in the shell's plain-view diagnostics overlay
|
|
||||||
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
|
|
||||||
/// and Close controls, added over whatever iris itself is drawing
|
|
||||||
/// rather than replacing it (unlike `android::view::show_renderer_error`,
|
|
||||||
/// which exists for the case the renderer can never recover from and
|
|
||||||
/// intentionally never returns). Called from a background task after
|
|
||||||
/// the keyboard-open delay (`bench_client.rs`'s `on_insets_changed`),
|
|
||||||
/// so the Java side hops onto the UI thread itself before touching the
|
|
||||||
/// view tree -- see that method's own comment.
|
|
||||||
pub fn show_diagnostics_overlay(&self, report: &str) -> bool {
|
|
||||||
self.try_show_diagnostics_overlay(report).is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_show_diagnostics_overlay(&self, report: &str) -> Option<()> {
|
|
||||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
|
||||||
let env: &mut JNIEnv = &mut guard;
|
|
||||||
let jreport = env.new_string(report).ok()?;
|
|
||||||
env.call_method(
|
|
||||||
self.view.as_obj(),
|
|
||||||
"showDiagnosticsOverlay",
|
|
||||||
"(Ljava/lang/String;)V",
|
|
||||||
&[JValue::Object(jreport.as_ref())],
|
|
||||||
)
|
|
||||||
.ok()?;
|
|
||||||
Some(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-112
@@ -1,55 +1,12 @@
|
|||||||
//! RUST.md's I5 Android integration: `transcript-ui`'s screen filling the
|
use crate::client::api::{ApiClient, UreqTransport};
|
||||||
//! whole window on android-view, against a real `ai-server` through
|
use crate::client::event_stream::{StreamItem, follow_session_events};
|
||||||
//! `client-core` -- the missing half `iris-android-app` (I2) only had for
|
use crate::client::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
||||||
//! `tabs-ui` until now. Behind the `transcript-screen` Cargo feature so the
|
|
||||||
//! plain build (`cargo ndk build`, no `--features`) stays exactly the tabs
|
|
||||||
//! demo I2/I4 already measured against.
|
|
||||||
//!
|
|
||||||
//! **Deliberate simplification, recorded rather than left to be
|
|
||||||
//! rediscovered (RUST.md's I5 box has the full account)**: there is no
|
|
||||||
//! session list and no enrollment UI here. The server, port, token and
|
|
||||||
//! pinned CA are baked in at build time (`build.rs`'s
|
|
||||||
//! `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA`), and the first
|
|
||||||
//! session `ApiClient::fetch_sessions` returns is opened automatically --
|
|
||||||
//! there is nothing to tap to get there, which is what `transcript-bench.sh`
|
|
||||||
//! and `ui-trace` need to land straight on the screen under test. A real
|
|
||||||
//! app needs `desktop-app`'s `EnrolledServer`/QR-link flow or E3's
|
|
||||||
//! Keystore-sealed `ServerConfig.kt`; building a second one of those was
|
|
||||||
//! not this pass's job.
|
|
||||||
//!
|
|
||||||
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
|
||||||
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
|
||||||
//! `client_core::transcript_fold`, a `generation` counter guarding against
|
|
||||||
//! a stale background response. What differs is only the redraw
|
|
||||||
//! mechanism: android-view has no `winit::EventLoopProxy`, so this uses
|
|
||||||
//! `iris::task::Tasks::redraw_handle` (new, added alongside this box) to
|
|
||||||
//! request a frame after each `TaskCtx::update` instead of relying on
|
|
||||||
//! `Tasks::spawn`'s single end-of-future redraw -- see that method's own
|
|
||||||
//! doc for why.
|
|
||||||
//!
|
|
||||||
//! **Streaming no longer costs a full rebuild** (fixed after the P0 gate
|
|
||||||
//! showed why it mattered -- 20 events/second means 20 rebuilds/second of
|
|
||||||
//! a ~3,200-row transcript otherwise): `apply_event` calls
|
|
||||||
//! `transcript_ui::TranscriptScreen::apply` with the item list before and
|
|
||||||
//! after `fold_event`, which updates only the row(s) that actually
|
|
||||||
//! changed (almost always just the one open assistant message) instead of
|
|
||||||
//! refolding and rebuilding every row. `rebuild_transcript` still runs
|
|
||||||
//! the whole widget tree once, for the opening page and for `apply`'s own
|
|
||||||
//! rare regroup fallback.
|
|
||||||
|
|
||||||
use client_core::api::{ApiClient, UreqTransport};
|
|
||||||
use client_core::event_stream::{StreamItem, follow_session_events};
|
|
||||||
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
|
||||||
use event_model::SeqEvent;
|
use event_model::SeqEvent;
|
||||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
mod pinned {
|
|
||||||
include!(concat!(env!("OUT_DIR"), "/pinned_config.rs"));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TranscriptClient {
|
pub struct TranscriptClient {
|
||||||
ui_state: AndroidUiState,
|
ui_state: AndroidUiState,
|
||||||
/// The screen's own content -- everything under the fixed
|
/// The screen's own content -- everything under the fixed
|
||||||
@@ -58,21 +15,9 @@ pub struct TranscriptClient {
|
|||||||
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
|
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
|
||||||
/// by rebuilding the session list beside it.
|
/// by rebuilding the session list beside it.
|
||||||
content: WeakWidget<WidgetPtr>,
|
content: WeakWidget<WidgetPtr>,
|
||||||
screen: Option<transcript_ui::TranscriptScreen>,
|
screen: Option<crate::ui::TranscriptScreen>,
|
||||||
/// The folded transcript as of the last rebuild -- kept here (not
|
|
||||||
/// re-derived) for the same reason `desktop-app`'s `Client::items`
|
|
||||||
/// exists: a live `StreamEvent` only carries one new wire event, and
|
|
||||||
/// `fold_event` needs everything folded so far to fold it in.
|
|
||||||
items: Vec<TranscriptItem>,
|
items: Vec<TranscriptItem>,
|
||||||
/// The session currently open -- `None` only before the first fetch
|
|
||||||
/// resolves. Read back by `apply_event`'s rebuild, which has no session
|
|
||||||
/// id of its own (a live `SeqEvent` doesn't carry one).
|
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
/// Bumped every time a new session load starts; a background response
|
|
||||||
/// checks it before touching state, so a slow reply for a session this
|
|
||||||
/// screen has moved on from can't overwrite what replaced it. There is
|
|
||||||
/// only ever one session here (no list to switch away to), but the
|
|
||||||
/// guard still matters for the *first* fetch racing a `stop`/`start`.
|
|
||||||
generation: Arc<AtomicU64>,
|
generation: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,41 +30,22 @@ impl HasAndroidUiState for TranscriptClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds one `UreqTransport` from the config `build.rs` baked in. Called
|
|
||||||
/// twice per session load, same as `desktop-app`'s `build_transport`
|
|
||||||
/// closure -- `ApiClient` and the live-stream follow each need their own,
|
|
||||||
/// since `UreqTransport` holds its own `ureq::Agent`.
|
|
||||||
fn build_transport() -> Result<UreqTransport, String> {
|
fn build_transport() -> Result<UreqTransport, String> {
|
||||||
let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT);
|
crate::android::enrollment::transport()
|
||||||
UreqTransport::new(
|
|
||||||
base_url,
|
|
||||||
pinned::TOKEN.to_string(),
|
|
||||||
pinned::CA_PEM.as_bytes(),
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||||
wtext(message.to_string())
|
wtext(message.to_string())
|
||||||
.color(Color::WHITE)
|
.color(PaintId::WHITE)
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.pad(16)
|
.pad(16)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any()
|
.any()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two named controls RUST.md's I5 box ("Measurements taken" (b))
|
|
||||||
/// drives by name over `ui-trace`, e.g. `ui-trace record --do "tap 'Frame
|
|
||||||
/// report'"`. `dumpsys gfxinfo` cannot see this screen's own GPU-drawn
|
|
||||||
/// frames at all -- this is the screen's own equivalent of the Compose
|
|
||||||
/// app's "Copy render timings" control, logged rather than clipboarded
|
|
||||||
/// (no clipboard wiring exists here) under this crate's own fixed
|
|
||||||
/// `android_logger` tag (`iris-android-app`, `lib.rs`'s `JNI_OnLoad`),
|
|
||||||
/// grep-able on the fixed string `"iris frame report"` the way
|
|
||||||
/// `transcript-bench.sh` greps `"ai-app render report"`.
|
|
||||||
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
|
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
|
||||||
type Rsc = AndroidRsc<TranscriptClient>;
|
type Rsc = AndroidRsc<TranscriptClient>;
|
||||||
let report_rect = rect(Color::rgb(50, 50, 60))
|
let report_rect = rect(Srgba8::rgb(50, 50, 60))
|
||||||
.on(
|
.on(
|
||||||
CursorSense::click(),
|
CursorSense::click(),
|
||||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
|
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
|
||||||
@@ -143,7 +69,7 @@ fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
|
|||||||
.pad(8)
|
.pad(8)
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
let reset_rect = rect(Color::rgb(70, 40, 40))
|
let reset_rect = rect(Srgba8::rgb(70, 40, 40))
|
||||||
.on(
|
.on(
|
||||||
CursorSense::click(),
|
CursorSense::click(),
|
||||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
||||||
@@ -173,7 +99,7 @@ impl AndroidAppState for TranscriptClient {
|
|||||||
.span(Dir::DOWN)
|
.span(Dir::DOWN)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any();
|
.any();
|
||||||
ui_state.set_root(tree);
|
ui_state.set_root(rsc, tree);
|
||||||
|
|
||||||
let mut client = Self {
|
let mut client = Self {
|
||||||
ui_state,
|
ui_state,
|
||||||
@@ -230,10 +156,6 @@ impl TranscriptClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads the opening page, then follows the live SSE stream for the
|
|
||||||
/// rest of this session's life -- `desktop-app`'s `select_session`
|
|
||||||
/// almost verbatim, with `Proxy::send_event` replaced by `ctx.update` +
|
|
||||||
/// `redraw.request_redraw()` (see this module's doc).
|
|
||||||
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
|
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
|
||||||
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
self.items.clear();
|
self.items.clear();
|
||||||
@@ -260,22 +182,14 @@ impl TranscriptClient {
|
|||||||
};
|
};
|
||||||
let api = ApiClient::new(rest);
|
let api = ApiClient::new(rest);
|
||||||
|
|
||||||
// The most recent 200 events, coalesced -- the same page size
|
|
||||||
// `desktop-app` uses; RUST.md's I3/history-paging work is what
|
|
||||||
// a real scrollback would reuse (out of scope here, same as
|
|
||||||
// E4).
|
|
||||||
let page: Result<Vec<serde_json::Value>, String> = api
|
let page: Result<Vec<serde_json::Value>, String> = api
|
||||||
.fetch_transcript_page(&session_id, None, 200, true)
|
.fetch_transcript_page(&session_id, None, 200, true)
|
||||||
.map_err(|e| e.to_string());
|
.map_err(|e| e.to_string());
|
||||||
// The wire `seq` of the last line, not a folded item's `seq()`
|
|
||||||
// -- see `client_core::transcript_fold::raw_seq`'s doc for why
|
|
||||||
// resuming from the latter re-delivers deltas already folded
|
|
||||||
// into an in-progress reply.
|
|
||||||
let after = page
|
let after = page
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|values| values.last())
|
.and_then(|values| values.last())
|
||||||
.and_then(client_core::transcript_fold::raw_seq)
|
.and_then(crate::client::transcript_fold::raw_seq)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let result = page.and_then(|values| fold_page(&values));
|
let result = page.and_then(|values| fold_page(&values));
|
||||||
|
|
||||||
@@ -301,12 +215,6 @@ impl TranscriptClient {
|
|||||||
if live_generation.load(Ordering::SeqCst) != my_generation {
|
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The outer closure here is an `FnMut` -- `follow_session_events`
|
|
||||||
// calls it once per line -- so it captures `live_generation` by
|
|
||||||
// move and re-clones it for each inner `ctx.update` closure
|
|
||||||
// rather than moving a shared `stop`-style helper into itself:
|
|
||||||
// a value moved out of an `FnMut`'s captures on one call leaves
|
|
||||||
// nothing there for the next.
|
|
||||||
let _ =
|
let _ =
|
||||||
follow_session_events(
|
follow_session_events(
|
||||||
&stream_transport,
|
&stream_transport,
|
||||||
@@ -335,10 +243,6 @@ impl TranscriptClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuilds the whole widget tree from `self.items` -- same tradeoff as
|
|
||||||
/// `desktop-app`'s `rebuild_transcript` (this module's doc comment).
|
|
||||||
/// Reads `self.session_id` rather than taking one, since every caller
|
|
||||||
/// (the opening page, and every live event) already has it set there.
|
|
||||||
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
|
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
|
||||||
let in_progress = self
|
let in_progress = self
|
||||||
.screen
|
.screen
|
||||||
@@ -347,7 +251,7 @@ impl TranscriptClient {
|
|||||||
.filter(|t| !t.is_empty());
|
.filter(|t| !t.is_empty());
|
||||||
|
|
||||||
let rows = group_tool_runs(&self.items);
|
let rows = group_tool_runs(&self.items);
|
||||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
let (screen, tree) = crate::ui::build_tree(rsc, rows);
|
||||||
|
|
||||||
if let Some(text) = in_progress {
|
if let Some(text) = in_progress {
|
||||||
screen.composer.field.edit(rsc).set(&text);
|
screen.composer.field.edit(rsc).set(&text);
|
||||||
@@ -371,12 +275,7 @@ impl TranscriptClient {
|
|||||||
let old_items = self.items.clone();
|
let old_items = self.items.clone();
|
||||||
self.items = fold_event(&self.items, event);
|
self.items = fold_event(&self.items, event);
|
||||||
match &self.screen {
|
match &self.screen {
|
||||||
// The common path: update only the row(s) that actually
|
|
||||||
// changed instead of refolding and rebuilding all ~3,200 of
|
|
||||||
// them per event (RUST.md's P0 streaming-phase fix).
|
|
||||||
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||||
// No screen yet (the opening page hasn't landed) -- build one
|
|
||||||
// the ordinary way once it has.
|
|
||||||
None => self.rebuild_transcript(rsc),
|
None => self.rebuild_transcript(rsc),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,25 +1,3 @@
|
|||||||
//! What a tool printed, with its terminal styling applied and everything
|
|
||||||
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
|
|
||||||
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
|
|
||||||
//! here, so a [`StyledText`] of plain text plus non-overlapping
|
|
||||||
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
|
|
||||||
//! [`Style`] onto whatever it draws with.
|
|
||||||
//!
|
|
||||||
//! Bash output arrives exactly as the program wrote it, escape sequences
|
|
||||||
//! included, and drawn verbatim those are line noise in the middle of the
|
|
||||||
//! thing being read. Stripping them all would be the other half-answer --
|
|
||||||
//! colour is often the whole of what a diff or a test run is saying.
|
|
||||||
//!
|
|
||||||
//! So the sequences that decide how text *looks* become spans, and every
|
|
||||||
//! other one is dropped rather than shown: the rest move a cursor around a
|
|
||||||
//! grid this is not, and "go to column 40" has no meaning in a scrolling
|
|
||||||
//! document.
|
|
||||||
//!
|
|
||||||
//! A carriage return is honoured the way a terminal honours it: what was
|
|
||||||
//! written since the last line break is thrown away and the line starts
|
|
||||||
//! again. That is what makes a progress bar show its final state rather
|
|
||||||
//! than every state it passed through.
|
|
||||||
|
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
||||||
@@ -38,18 +16,10 @@ impl Rgb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sixteen colours a terminal program names, and the two it assumes.
|
|
||||||
///
|
|
||||||
/// Its own palette rather than the syntax one: a program that prints in red
|
|
||||||
/// has chosen red, where a highlighter's colours are this app's reading of
|
|
||||||
/// somebody else's code.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AnsiPalette {
|
pub struct AnsiPalette {
|
||||||
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
|
|
||||||
pub colours: [Rgb; 16],
|
pub colours: [Rgb; 16],
|
||||||
/// What uncoloured text is, needed only where a style has to state a colour.
|
|
||||||
pub foreground: Rgb,
|
pub foreground: Rgb,
|
||||||
/// What the text sits on, needed for reverse video.
|
|
||||||
pub background: Rgb,
|
pub background: Rgb,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +37,6 @@ pub struct Style {
|
|||||||
pub strikethrough: bool,
|
pub strikethrough: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plain text plus the non-overlapping, ordered spans that style parts of it
|
|
||||||
/// -- this crate's stand-in for Compose's `AnnotatedString`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Default)]
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
pub struct StyledText {
|
pub struct StyledText {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
@@ -87,11 +55,7 @@ impl StyledText {
|
|||||||
const ESC: char = '\u{1B}';
|
const ESC: char = '\u{1B}';
|
||||||
const BELL: char = '\u{7}';
|
const BELL: char = '\u{7}';
|
||||||
|
|
||||||
/// [text] with its terminal styling applied and everything else taken out;
|
|
||||||
/// see the module doc.
|
|
||||||
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||||
// The common case by a long way -- nothing to do, and nothing allocated
|
|
||||||
// to find that out.
|
|
||||||
if !text.contains(ESC) && !text.contains('\r') {
|
if !text.contains(ESC) && !text.contains('\r') {
|
||||||
return StyledText::plain(text.to_string());
|
return StyledText::plain(text.to_string());
|
||||||
}
|
}
|
||||||
@@ -118,19 +82,12 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
|
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
|
||||||
// A bare carriage return rewrites the line. One before a newline
|
|
||||||
// is the other half of a Windows line ending: it rewrites
|
|
||||||
// nothing, and it is dropped rather than kept, since that pair
|
|
||||||
// is one line break.
|
|
||||||
flush(&mut plain, sgr, &mut runs);
|
flush(&mut plain, sgr, &mut runs);
|
||||||
drop_line(&mut runs);
|
drop_line(&mut runs);
|
||||||
at += 1;
|
at += 1;
|
||||||
} else if c == '\r' {
|
} else if c == '\r' {
|
||||||
at += 1;
|
at += 1;
|
||||||
} else if c >= ' ' || c == '\n' || c == '\t' {
|
} else if c >= ' ' || c == '\n' || c == '\t' {
|
||||||
// Everything printable, plus the two control characters that are
|
|
||||||
// layout rather than terminal commands. A stray bell or
|
|
||||||
// backspace goes for the same reason a cursor move does.
|
|
||||||
plain.push(c);
|
plain.push(c);
|
||||||
at += 1;
|
at += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -151,8 +108,6 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
|||||||
StyledText { text: out, spans }
|
StyledText { text: out, spans }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Throws away everything written since the last line break, as a carriage
|
|
||||||
/// return does.
|
|
||||||
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
||||||
while let Some((text, style)) = runs.pop() {
|
while let Some((text, style)) = runs.pop() {
|
||||||
if let Some(break_at) = text.rfind('\n') {
|
if let Some(break_at) = text.rfind('\n') {
|
||||||
@@ -162,7 +117,6 @@ fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bytes that end a CSI sequence.
|
|
||||||
fn is_csi_final(c: char) -> bool {
|
fn is_csi_final(c: char) -> bool {
|
||||||
('@'..='~').contains(&c)
|
('@'..='~').contains(&c)
|
||||||
}
|
}
|
||||||
@@ -184,10 +138,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
|||||||
end += 1;
|
end += 1;
|
||||||
}
|
}
|
||||||
if end >= chars.len() {
|
if end >= chars.len() {
|
||||||
// Cut off mid-sequence, which is what a stream that has not
|
|
||||||
// finished arriving looks like: drop the fragment rather
|
|
||||||
// than printing it, and the whole sequence arrives with the
|
|
||||||
// next delta.
|
|
||||||
chars.len()
|
chars.len()
|
||||||
} else {
|
} else {
|
||||||
let params: String = chars[at + 2..end].iter().collect();
|
let params: String = chars[at + 2..end].iter().collect();
|
||||||
@@ -196,8 +146,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
']' | 'P' | 'X' | '^' | '_' => {
|
']' | 'P' | 'X' | '^' | '_' => {
|
||||||
// Runs to a string terminator: `ESC \`, or the bell that xterm
|
|
||||||
// allows after an OSC.
|
|
||||||
let mut end = at + 2;
|
let mut end = at + 2;
|
||||||
while end < chars.len() {
|
while end < chars.len() {
|
||||||
if chars[end] == BELL {
|
if chars[end] == BELL {
|
||||||
@@ -214,7 +162,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything an SGR sequence can turn on, as the terminal tracks it.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
struct Sgr {
|
struct Sgr {
|
||||||
fg: Option<Rgb>,
|
fg: Option<Rgb>,
|
||||||
@@ -227,7 +174,6 @@ struct Sgr {
|
|||||||
reverse: bool,
|
reverse: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much of its colour dim text keeps: enough to read, little enough to recede.
|
|
||||||
const DIM_ALPHA: f32 = 0.65;
|
const DIM_ALPHA: f32 = 0.65;
|
||||||
|
|
||||||
impl Sgr {
|
impl Sgr {
|
||||||
@@ -242,7 +188,6 @@ impl Sgr {
|
|||||||
reverse: false,
|
reverse: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `None` while nothing is set, so unstyled output costs no spans at all.
|
|
||||||
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
||||||
if *self == Sgr::PLAIN {
|
if *self == Sgr::PLAIN {
|
||||||
return None;
|
return None;
|
||||||
@@ -275,15 +220,7 @@ impl Sgr {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This state with `params` applied -- one `ESC[...m`, which carries any
|
|
||||||
/// number of them.
|
|
||||||
///
|
|
||||||
/// A code this does not model is ignored rather than reset from: the
|
|
||||||
/// program meant something by it, and starting again would also drop
|
|
||||||
/// the codes beside it that are understood.
|
|
||||||
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
|
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
|
||||||
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
|
|
||||||
// zero too.
|
|
||||||
let codes: Vec<i64> = params
|
let codes: Vec<i64> = params
|
||||||
.split(';')
|
.split(';')
|
||||||
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
|
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
|
||||||
@@ -377,12 +314,6 @@ impl Sgr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The colour named by a `38`/`48` at `at`, and the index of that colour's
|
|
||||||
/// last parameter.
|
|
||||||
///
|
|
||||||
/// Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal
|
|
||||||
/// one. The first sixteen of that table are the palette's own, so a program
|
|
||||||
/// asking for "colour 1" through either spelling gets the same red.
|
|
||||||
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
|
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
|
||||||
match codes.get(at + 1) {
|
match codes.get(at + 1) {
|
||||||
Some(&5) => match codes.get(at + 2) {
|
Some(&5) => match codes.get(at + 2) {
|
||||||
@@ -409,11 +340,8 @@ fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
|
|
||||||
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||||
|
|
||||||
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
|
|
||||||
/// grey ramp.
|
|
||||||
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
||||||
if n < 0 {
|
if n < 0 {
|
||||||
palette.foreground
|
palette.foreground
|
||||||
@@ -434,8 +362,6 @@ fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
|
|
||||||
/// white foreground, black background.
|
|
||||||
fn palette() -> AnsiPalette {
|
fn palette() -> AnsiPalette {
|
||||||
let mut colours = [Rgb::new(0, 0, 0); 16];
|
let mut colours = [Rgb::new(0, 0, 0); 16];
|
||||||
for (i, c) in colours.iter_mut().enumerate() {
|
for (i, c) in colours.iter_mut().enumerate() {
|
||||||
@@ -452,8 +378,6 @@ mod tests {
|
|||||||
ansi_styled(text, &palette())
|
ansi_styled(text, &palette())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The style covering the first character of `word`, or `None` where
|
|
||||||
/// nothing styles it.
|
|
||||||
fn style_over(text: &str, word: &str) -> Option<Style> {
|
fn style_over(text: &str, word: &str) -> Option<Style> {
|
||||||
let out = styled(text);
|
let out = styled(text);
|
||||||
let at = out
|
let at = out
|
||||||
@@ -509,8 +433,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
|
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
|
||||||
// A cursor move, an erase, an OSC window title with its bell, and a
|
|
||||||
// bare two-character escape.
|
|
||||||
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
|
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
|
||||||
assert_eq!(styled(&text).text, "abcde");
|
assert_eq!(styled(&text).text, "abcde");
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,9 @@
|
|||||||
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
|
|
||||||
//! module doc for the table); the SSE half is [`crate::event_stream`].
|
|
||||||
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
|
|
||||||
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
|
|
||||||
//! which do not.
|
|
||||||
//!
|
|
||||||
//! Network I/O sits behind the [`Transport`] trait so the rest of this
|
|
||||||
//! crate, and anything built on it, can be tested against a fake one with
|
|
||||||
//! no server involved. [`UreqTransport`] is the only real implementation.
|
|
||||||
|
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
|
||||||
use event_model::SeqEvent;
|
use event_model::SeqEvent;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
/// A request that did not produce what it asked for, carrying the server's
|
|
||||||
/// own wording where it sent some.
|
|
||||||
///
|
|
||||||
/// `status` is the HTTP status where there was a response at all, and
|
/// `status` is the HTTP status where there was a response at all, and
|
||||||
/// `None` where the server was never reached -- mirroring `ApiException` in
|
/// `None` where the server was never reached -- mirroring `ApiException` in
|
||||||
/// `Api.kt`.
|
/// `Api.kt`.
|
||||||
@@ -33,8 +20,6 @@ impl std::fmt::Display for ApiError {
|
|||||||
}
|
}
|
||||||
impl std::error::Error for ApiError {}
|
impl std::error::Error for ApiError {}
|
||||||
|
|
||||||
/// A request body to send, in whichever of the two shapes the surface
|
|
||||||
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
|
|
||||||
pub enum Body {
|
pub enum Body {
|
||||||
Json(Value),
|
Json(Value),
|
||||||
Bytes {
|
Bytes {
|
||||||
@@ -51,10 +36,7 @@ pub struct RawResponse {
|
|||||||
pub body: Vec<u8>,
|
pub body: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The network boundary this crate's pure logic is kept out from behind.
|
|
||||||
/// `server/src/routes.rs`'s module doc is the surface this drives.
|
|
||||||
pub trait Transport: Send + Sync {
|
pub trait Transport: Send + Sync {
|
||||||
/// One request/response call -- everything but the long-lived SSE GETs.
|
|
||||||
fn request(
|
fn request(
|
||||||
&self,
|
&self,
|
||||||
method: &str,
|
method: &str,
|
||||||
@@ -62,10 +44,6 @@ pub trait Transport: Send + Sync {
|
|||||||
body: Option<Body>,
|
body: Option<Body>,
|
||||||
) -> Result<RawResponse, ApiError>;
|
) -> Result<RawResponse, ApiError>;
|
||||||
|
|
||||||
/// Opens `path` and answers a reader over the response body, for a
|
|
||||||
/// caller that reads it as a stream rather than all at once (the SSE
|
|
||||||
/// connections in [`crate::event_stream`]). Fails the same way
|
|
||||||
/// [`Transport::request`] does for a non-2xx response.
|
|
||||||
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
|
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,10 +82,6 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A client-core equivalent of `requestFromServer` plus the typed calls
|
|
||||||
/// built on it. Holds no state of its own beyond the transport -- the
|
|
||||||
/// session id or setup id a call is about is a parameter, per this
|
|
||||||
/// project's "ask for the least you need".
|
|
||||||
pub struct ApiClient<T: Transport> {
|
pub struct ApiClient<T: Transport> {
|
||||||
transport: T,
|
transport: T,
|
||||||
}
|
}
|
||||||
@@ -265,7 +239,7 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
/// A page of transcript history. `before` is the newest-first cursor
|
/// A page of transcript history. `before` is the newest-first cursor
|
||||||
/// (server default is "the newest page" when absent, which a caller
|
/// (server default is "the newest page" when absent, which a caller
|
||||||
/// gets by passing `None`); the events themselves are handed back as
|
/// gets by passing `None`); the events themselves are handed back as
|
||||||
/// [`event_model::SeqEvent`] via `crate::event_stream`'s parsing, kept
|
/// [`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
|
/// 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.
|
/// lines (for the transcript cache) is not forced to parse them.
|
||||||
pub fn fetch_transcript_page(
|
pub fn fetch_transcript_page(
|
||||||
@@ -282,19 +256,6 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A page of transcript history, each line handed back paired with the
|
|
||||||
/// exact text it came from, and bounded below by `after` -- the shape
|
|
||||||
/// `crate::transcript_source::TranscriptSource` needs to store what it
|
|
||||||
/// fetched in the transcript cache without a second round trip to fetch
|
|
||||||
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
|
|
||||||
///
|
|
||||||
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
|
|
||||||
/// parsed [`Value`], so the stored line is the exact bytes the server
|
|
||||||
/// sent (key order and float literal included) rather than this
|
|
||||||
/// crate's own idea of how to write them back out -- the cache and a
|
|
||||||
/// live SSE frame must agree byte-for-byte on the same event, which is
|
|
||||||
/// exactly what caught the `serde_json` float-rounding bug this
|
|
||||||
/// project's `AGENTS.md` records.
|
|
||||||
pub fn fetch_transcript_lines(
|
pub fn fetch_transcript_lines(
|
||||||
&self,
|
&self,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
@@ -320,9 +281,6 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
|
|
||||||
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
|
|
||||||
/// parameter is written rather than keeping two copies to drift.
|
|
||||||
fn transcript_path(
|
fn transcript_path(
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
before: Option<u64>,
|
before: Option<u64>,
|
||||||
@@ -343,12 +301,6 @@ fn transcript_path(
|
|||||||
path
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
|
||||||
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
|
|
||||||
/// poll). Verifies the server's leaf against a single pinned CA, the way
|
|
||||||
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
|
|
||||||
/// store -- the server's certificate is self-signed on purpose (see
|
|
||||||
/// `wg-app-link`).
|
|
||||||
pub struct UreqTransport {
|
pub struct UreqTransport {
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
@@ -356,8 +308,6 @@ pub struct UreqTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UreqTransport {
|
impl UreqTransport {
|
||||||
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
|
|
||||||
/// exactly as read from `certs/ca.pem`.
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
base_url: impl Into<String>,
|
base_url: impl Into<String>,
|
||||||
token: impl Into<String>,
|
token: impl Into<String>,
|
||||||
@@ -372,10 +322,6 @@ impl UreqTransport {
|
|||||||
.build();
|
.build();
|
||||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||||
.tls_config(tls_config)
|
.tls_config(tls_config)
|
||||||
// Read the body ourselves on every status, the way
|
|
||||||
// `requestFromServer` does: the server's own error wording is
|
|
||||||
// in the body of a 4xx/5xx, and the default behaviour throws
|
|
||||||
// it away before this code can read it.
|
|
||||||
.http_status_as_error(false)
|
.http_status_as_error(false)
|
||||||
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
||||||
.build()
|
.build()
|
||||||
@@ -453,9 +399,6 @@ impl Transport for UreqTransport {
|
|||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", &auth)
|
.header("Authorization", &auth)
|
||||||
.header("Accept", "text/event-stream")
|
.header("Accept", "text/event-stream")
|
||||||
// No read timeout: between events there is nothing to read for
|
|
||||||
// as long as the thing being followed is idle, mirroring
|
|
||||||
// `EventStream.kt`'s `readTimeout = 0`.
|
|
||||||
.config()
|
.config()
|
||||||
.timeout_recv_response(None)
|
.timeout_recv_response(None)
|
||||||
.build()
|
.build()
|
||||||
@@ -481,9 +424,6 @@ fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The 401 wording matches `Api.kt`'s, since that message is instructions
|
|
||||||
/// for the reader rather than a diagnostic -- see this project's UI rule
|
|
||||||
/// about shortening a failure in one place rather than at each display site.
|
|
||||||
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
|
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
|
||||||
let detail = String::from_utf8_lossy(body).trim().to_string();
|
let detail = String::from_utf8_lossy(body).trim().to_string();
|
||||||
let message = if status == 401 {
|
let message = if status == 401 {
|
||||||
@@ -507,8 +447,6 @@ mod tests {
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// A transport with no network at all, for the pure-logic tests this
|
|
||||||
/// module can run without a server.
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct FakeTransport {
|
struct FakeTransport {
|
||||||
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
||||||
@@ -569,7 +507,6 @@ mod tests {
|
|||||||
assert_eq!(sessions.len(), 1);
|
assert_eq!(sessions.len(), 1);
|
||||||
assert_eq!(sessions[0].id, "s1");
|
assert_eq!(sessions[0].id, "s1");
|
||||||
assert_eq!(sessions[0].setup_name, "desktop");
|
assert_eq!(sessions[0].setup_name, "desktop");
|
||||||
// Defaults for fields the server omits.
|
|
||||||
assert!(sessions[0].notify);
|
assert!(sessions[0].notify);
|
||||||
assert_eq!(sessions[0].model, None);
|
assert_eq!(sessions[0].model, None);
|
||||||
}
|
}
|
||||||
@@ -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(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
//! The SSE half of the API: one long-lived GET per open session screen,
|
|
||||||
//! replaying the transcript after a cursor and then following it live.
|
|
||||||
//! Ported from `app/.../EventStream.kt`; the framing itself is
|
|
||||||
//! [`crate::sse`].
|
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
use event_model::SeqEvent;
|
use event_model::SeqEvent;
|
||||||
|
|
||||||
use crate::api::{ApiError, Transport};
|
use crate::client::api::{ApiError, Transport};
|
||||||
use crate::sse::SseReader;
|
use crate::client::sse::SseReader;
|
||||||
|
|
||||||
/// The frame name the server uses to say a cursor was too far behind to
|
/// 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`.
|
/// continue from. Must match `send_backlog` in `server/src/routes.rs`.
|
||||||
@@ -19,19 +14,8 @@ const RESET_EVENT: &str = "reset";
|
|||||||
/// callbacks were for, as a single enum instead, since Rust has no
|
/// callbacks were for, as a single enum instead, since Rust has no
|
||||||
/// equivalent of handing three closures to one blocking call.
|
/// equivalent of handing three closures to one blocking call.
|
||||||
pub enum StreamItem {
|
pub enum StreamItem {
|
||||||
/// The connection was accepted; the measured moment the stream is live
|
|
||||||
/// (see `EventStream.kt`'s doc on `onOpen` for why this, not the first
|
|
||||||
/// event, is what clears a previous failure on screen).
|
|
||||||
Open,
|
Open,
|
||||||
/// The cursor was too far behind to continue from: everything already
|
|
||||||
/// displayed is stale, and the events that follow are a fresh window.
|
|
||||||
/// Arrives before those events, so a caller that clears on it stays in
|
|
||||||
/// order.
|
|
||||||
Reset,
|
Reset,
|
||||||
/// One event, as both the raw line the transcript cache stores and the
|
|
||||||
/// parsed [`SeqEvent`] the fold works from -- they have to be the same
|
|
||||||
/// line, so both travel together rather than being parsed twice from
|
|
||||||
/// two call sites.
|
|
||||||
Event { raw: String, event: SeqEvent },
|
Event { raw: String, event: SeqEvent },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +43,6 @@ pub fn follow_session_events(
|
|||||||
let Some(frame) = reader.feed_line(&line) else {
|
let Some(frame) = reader.feed_line(&line) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// A named frame carries no payload and a data frame has no name.
|
|
||||||
if frame.name.as_deref() == Some(RESET_EVENT) {
|
if frame.name.as_deref() == Some(RESET_EVENT) {
|
||||||
if !on_item(StreamItem::Reset) {
|
if !on_item(StreamItem::Reset) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -83,7 +66,7 @@ pub fn follow_session_events(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::{Body, RawResponse};
|
use crate::client::api::{Body, RawResponse};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
struct FixtureTransport {
|
struct FixtureTransport {
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
//! A language the highlighter can colour, and the data-driven [`Rules`] each
|
|
||||||
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
|
|
||||||
//! why nearly every language is a row of data read by one shared scanner,
|
|
||||||
//! with Markdown the one exception (`super::markdown`).
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
@@ -32,8 +27,6 @@ pub enum Language {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Language {
|
impl Language {
|
||||||
/// Every value, for the same exhaustiveness check the Kotlin test runs
|
|
||||||
/// (`Language.entries`).
|
|
||||||
pub const ALL: [Language; 22] = [
|
pub const ALL: [Language; 22] = [
|
||||||
Language::C,
|
Language::C,
|
||||||
Language::Coffeescript,
|
Language::Coffeescript,
|
||||||
@@ -60,24 +53,14 @@ impl Language {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`super::scan`] needs to know about one language -- data, not code,
|
|
||||||
/// so that adding a language is a row here rather than a branch anywhere.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Rules {
|
pub struct Rules {
|
||||||
/// Words drawn as keywords. Only plain words; the scanner cannot reach
|
|
||||||
/// anything else.
|
|
||||||
pub keywords: HashSet<&'static str>,
|
pub keywords: HashSet<&'static str>,
|
||||||
/// Tokens that open a comment running to the end of the line.
|
|
||||||
pub line_comments: Vec<&'static str>,
|
pub line_comments: Vec<&'static str>,
|
||||||
/// Whether `line_comments` count only at the start of a word. The shells
|
|
||||||
/// need it: `$#`, `${#x}` and `a#b` are not comments.
|
|
||||||
pub line_comments_at_word_start: bool,
|
pub line_comments_at_word_start: bool,
|
||||||
pub block_comment: Option<BlockComment>,
|
pub block_comment: Option<BlockComment>,
|
||||||
/// The string forms. The longest opener that matches wins, so `"""` is
|
|
||||||
/// tried before `"`.
|
|
||||||
pub quotes: Vec<Quote>,
|
pub quotes: Vec<Quote>,
|
||||||
pub attributes: Attributes,
|
pub attributes: Attributes,
|
||||||
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
|
|
||||||
pub raw_strings: bool,
|
pub raw_strings: bool,
|
||||||
/// Rust: `'` opens a character literal only when a backslash or one
|
/// Rust: `'` opens a character literal only when a backslash or one
|
||||||
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
||||||
@@ -91,8 +74,6 @@ pub struct BlockComment {
|
|||||||
pub nests: bool,
|
pub nests: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One string form. `escapes` is whether a backslash escapes the closer
|
|
||||||
/// (and itself).
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Quote {
|
pub struct Quote {
|
||||||
pub open: &'static str,
|
pub open: &'static str,
|
||||||
@@ -100,18 +81,13 @@ pub struct Quote {
|
|||||||
pub escapes: bool,
|
pub escapes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What opens a metadata span, of the shapes that exist across these languages.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub enum Attributes {
|
pub enum Attributes {
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
None,
|
||||||
/// `@` and a word: Kotlin and Java annotations, Python decorators.
|
|
||||||
AtWord,
|
AtWord,
|
||||||
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
|
|
||||||
HashBracket,
|
HashBracket,
|
||||||
/// `#` at the start of a line, to the end of it: the C preprocessor.
|
|
||||||
HashLine,
|
HashLine,
|
||||||
/// `[` at the start of a line through the matching `]`: a TOML table header.
|
|
||||||
LineBracket,
|
LineBracket,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,10 +127,6 @@ fn words(list: &'static str) -> HashSet<&'static str> {
|
|||||||
list.split_whitespace().collect()
|
list.split_whitespace().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rules for one language. A `match` rather than a lazily-built map --
|
|
||||||
/// there is no once-per-process cost worth paying for in a language table
|
|
||||||
/// this small, and it sidesteps the Kotlin version's own workaround for
|
|
||||||
/// property initialization order.
|
|
||||||
pub fn rules_for(language: Language) -> Rules {
|
pub fn rules_for(language: Language) -> Rules {
|
||||||
match language {
|
match language {
|
||||||
Language::C => Rules {
|
Language::C => Rules {
|
||||||
@@ -180,8 +152,6 @@ pub fn rules_for(language: Language) -> Rules {
|
|||||||
quotes: vec![DOUBLE, SINGLE],
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
// `###` opens and closes a block comment and `#` opens a line one,
|
|
||||||
// which is why the scanner tries the block opener first.
|
|
||||||
Language::Coffeescript => Rules {
|
Language::Coffeescript => Rules {
|
||||||
keywords: words(KEYWORDS_COFFEESCRIPT),
|
keywords: words(KEYWORDS_COFFEESCRIPT),
|
||||||
line_comments: vec!["#"],
|
line_comments: vec!["#"],
|
||||||
@@ -318,7 +288,6 @@ pub fn rules_for(language: Language) -> Rules {
|
|||||||
keywords: words(KEYWORDS_SHELL),
|
keywords: words(KEYWORDS_SHELL),
|
||||||
line_comments: vec!["#"],
|
line_comments: vec!["#"],
|
||||||
line_comments_at_word_start: true,
|
line_comments_at_word_start: true,
|
||||||
// A shell's single quotes are literal: `'a\'` is not one string.
|
|
||||||
quotes: vec![
|
quotes: vec![
|
||||||
DOUBLE,
|
DOUBLE,
|
||||||
Quote {
|
Quote {
|
||||||
@@ -373,16 +342,10 @@ pub fn rules_for(language: Language) -> Rules {
|
|||||||
attributes: Attributes::AtWord,
|
attributes: Attributes::AtWord,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
// Markdown has no token rules; see `super::markdown::scan_markdown`.
|
|
||||||
Language::Markdown => Rules::default(),
|
Language::Markdown => Rules::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The keyword sets. Every list below other than RON, TOML, fish and JSON
|
|
||||||
// came from dev.snipme:highlights 1.1.0 (Apache-2.0), the library the
|
|
||||||
// Kotlin scanner replaced, so that no fence which was coloured there turns
|
|
||||||
// plain here either.
|
|
||||||
|
|
||||||
const KEYWORDS_C: &str =
|
const KEYWORDS_C: &str =
|
||||||
"auto break case char const continue default do double else enum extern float for goto if
|
"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
|
int long register return short signed sizeof static struct switch typedef union unsigned
|
||||||
@@ -416,9 +379,6 @@ const KEYWORDS_DART: &str =
|
|||||||
required rethrow return sealed set show static super switch this throw true try var void
|
required rethrow return sealed set show static super switch this throw true try var void
|
||||||
when with while yield";
|
when with while yield";
|
||||||
|
|
||||||
/// fish is not in the library at all, so its fences are drawn plain today.
|
|
||||||
/// The list is the shell's own words, which is what a fish fence is mostly
|
|
||||||
/// made of.
|
|
||||||
const KEYWORDS_FISH: &str =
|
const KEYWORDS_FISH: &str =
|
||||||
"and begin break builtin case command continue else end exec for function if in not or
|
"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";
|
return switch while set echo test string math read source";
|
||||||
@@ -466,7 +426,6 @@ const KEYWORDS_PYTHON: &str =
|
|||||||
for from global if import in is lambda nonlocal not or pass raise return try while with
|
for from global if import in is lambda nonlocal not or pass raise return try while with
|
||||||
yield";
|
yield";
|
||||||
|
|
||||||
/// RON is not in the library either; these are the words a RON file can hold.
|
|
||||||
const KEYWORDS_RON: &str = "true false Some None inf NaN";
|
const KEYWORDS_RON: &str = "true false Some None inf NaN";
|
||||||
|
|
||||||
const KEYWORDS_RUBY: &str =
|
const KEYWORDS_RUBY: &str =
|
||||||
@@ -495,8 +454,6 @@ const KEYWORDS_SWIFT: &str =
|
|||||||
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
||||||
unowned weak willSet";
|
unowned weak willSet";
|
||||||
|
|
||||||
/// TOML is not in the library; `inf` and `nan` are values rather than
|
|
||||||
/// names, like the booleans.
|
|
||||||
const KEYWORDS_TOML: &str = "true false inf nan";
|
const KEYWORDS_TOML: &str = "true false inf nan";
|
||||||
|
|
||||||
const KEYWORDS_TYPESCRIPT: &str =
|
const KEYWORDS_TYPESCRIPT: &str =
|
||||||
@@ -518,8 +475,6 @@ pub fn fence_language(name: Option<&str>) -> Option<Language> {
|
|||||||
.map(|(_, language)| *language)
|
.map(|(_, language)| *language)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The highlighter's language for a *file*, from its name.
|
|
||||||
///
|
|
||||||
/// The extension is the part after the *last* dot, which is what makes
|
/// 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
|
/// `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
|
/// extension, it has a name that starts with a dot. A name with no dot at
|
||||||
@@ -1,26 +1,8 @@
|
|||||||
//! Markdown read into the spans that carry a colour -- a ```markdown fence
|
|
||||||
//! in a reply, and a `.md` file in the viewer. Ported from
|
|
||||||
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
|
|
||||||
//! scanner rather than a row of [`super::Rules`] (what a character means
|
|
||||||
//! depends on where it sits, not on what it is) and why an indented code
|
|
||||||
//! block is deliberately not recognised.
|
|
||||||
//!
|
|
||||||
//! Structure is read a line at a time and each line's prose left to right,
|
|
||||||
//! except the two decisions that are not: a fenced block is state carried
|
|
||||||
//! forward, and a table is found by its delimiter row, which comes after
|
|
||||||
//! the header it belongs to (the one place here that looks ahead).
|
|
||||||
|
|
||||||
use super::{Kind, Span};
|
use super::{Kind, Span};
|
||||||
|
|
||||||
/// The characters an unordered list may be bulleted with.
|
|
||||||
const BULLETS: &str = "-*+";
|
const BULLETS: &str = "-*+";
|
||||||
/// The characters a thematic break, or a setext heading's underline, can be
|
|
||||||
/// drawn with.
|
|
||||||
const RULE_MARKERS: &str = "-*_=";
|
const RULE_MARKERS: &str = "-*_=";
|
||||||
/// The characters that can open emphasis, strong emphasis or a strikethrough.
|
|
||||||
const EMPHASIS: &str = "*_~";
|
const EMPHASIS: &str = "*_~";
|
||||||
/// Characters that end a bare URL wherever they appear, and ones only
|
|
||||||
/// trimmed off the end.
|
|
||||||
const URL_STOPS: &str = "<>\"'`|";
|
const URL_STOPS: &str = "<>\"'`|";
|
||||||
const URL_TRAILING: &str = ".,:;!?";
|
const URL_TRAILING: &str = ".,:;!?";
|
||||||
|
|
||||||
@@ -46,15 +28,10 @@ impl MarkdownScanner {
|
|||||||
// The delimiter run that opened the fenced block we are inside, or
|
// The delimiter run that opened the fenced block we are inside, or
|
||||||
// None between them.
|
// None between them.
|
||||||
let mut fence: Option<Vec<char>> = None;
|
let mut fence: Option<Vec<char>> = None;
|
||||||
// Whether the row above was part of a table, which is what makes
|
|
||||||
// this one a body row.
|
|
||||||
let mut table = false;
|
let mut table = false;
|
||||||
loop {
|
loop {
|
||||||
let end = self.line_end(at);
|
let end = self.line_end(at);
|
||||||
if let Some(open) = fence.clone() {
|
if let Some(open) = fence.clone() {
|
||||||
// The content and the closing line alike: a fence is one
|
|
||||||
// block of code, and its own delimiters belong to it the
|
|
||||||
// way a string's quotes belong to the string.
|
|
||||||
self.emit(at, end, Kind::String);
|
self.emit(at, end, Kind::String);
|
||||||
if self.closes_fence(at, end, &open) {
|
if self.closes_fence(at, end, &open) {
|
||||||
fence = None;
|
fence = None;
|
||||||
@@ -76,7 +53,6 @@ impl MarkdownScanner {
|
|||||||
self.spans
|
self.spans
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The end of the line beginning at `at`: the newline, or the end of the text.
|
|
||||||
fn line_end(&self, at: usize) -> usize {
|
fn line_end(&self, at: usize) -> usize {
|
||||||
self.code[at..]
|
self.code[at..]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -85,8 +61,6 @@ impl MarkdownScanner {
|
|||||||
.unwrap_or(self.code.len())
|
.unwrap_or(self.code.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line that is not inside a fence, and whether the table it may be
|
|
||||||
/// part of is still open.
|
|
||||||
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
|
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
|
||||||
if self.table_delimiter(start, end) {
|
if self.table_delimiter(start, end) {
|
||||||
let indented = self.indented(start, end);
|
let indented = self.indented(start, end);
|
||||||
@@ -102,13 +76,11 @@ impl MarkdownScanner {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A line of nothing but pipes, dashes, alignment colons and space, with
|
|
||||||
/// one of each needed.
|
|
||||||
fn table_delimiter(&self, start: usize, end: usize) -> bool {
|
fn table_delimiter(&self, start: usize, end: usize) -> bool {
|
||||||
let mut dashes = false;
|
let mut dashes = false;
|
||||||
let mut pipes = false;
|
let mut pipes = false;
|
||||||
for at in self.indented(start, end)..end {
|
for c in &self.code[self.indented(start, end)..end] {
|
||||||
match self.code[at] {
|
match c {
|
||||||
'-' => dashes = true,
|
'-' => dashes = true,
|
||||||
'|' => pipes = true,
|
'|' => pipes = true,
|
||||||
':' | ' ' | '\t' => {}
|
':' | ' ' | '\t' => {}
|
||||||
@@ -132,7 +104,6 @@ impl MarkdownScanner {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A table row: the pipes are the structure, and what is between them is prose.
|
|
||||||
fn table_row(&mut self, start: usize, end: usize) {
|
fn table_row(&mut self, start: usize, end: usize) {
|
||||||
let mut at = self.indented(start, end);
|
let mut at = self.indented(start, end);
|
||||||
let mut cell = at;
|
let mut cell = at;
|
||||||
@@ -151,7 +122,6 @@ impl MarkdownScanner {
|
|||||||
self.inline(cell, end);
|
self.inline(cell, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spans, coalesced with the one before when they touch and agree.
|
|
||||||
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
|
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
|
||||||
if end <= start {
|
if end <= start {
|
||||||
return;
|
return;
|
||||||
@@ -166,7 +136,6 @@ impl MarkdownScanner {
|
|||||||
self.spans.push(Span { start, end, kind });
|
self.spans.push(Span { start, end, kind });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The first character of the line at or after `start` that is not indentation.
|
|
||||||
fn indented(&self, start: usize, end: usize) -> usize {
|
fn indented(&self, start: usize, end: usize) -> usize {
|
||||||
let mut at = start;
|
let mut at = start;
|
||||||
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
||||||
@@ -175,8 +144,6 @@ impl MarkdownScanner {
|
|||||||
at
|
at
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The run of backticks or tildes that could open or close a fence on
|
|
||||||
/// this line, or `None`.
|
|
||||||
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
|
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
|
||||||
let at = self.indented(start, end);
|
let at = self.indented(start, end);
|
||||||
if at == end {
|
if at == end {
|
||||||
@@ -193,20 +160,14 @@ impl MarkdownScanner {
|
|||||||
if run - at >= 3 { Some((at, run)) } else { None }
|
if run - at >= 3 { Some((at, run)) } else { None }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws an opening fence line and answers its delimiter, or `None` if
|
|
||||||
/// this is not one.
|
|
||||||
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
|
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
|
||||||
let (run_start, run_end) = self.fence_run(start, end)?;
|
let (run_start, run_end) = self.fence_run(start, end)?;
|
||||||
self.emit(run_start, run_end, Kind::String);
|
self.emit(run_start, run_end, Kind::String);
|
||||||
// The info word is what the fence is a fence *of*, which is
|
|
||||||
// metadata about the block rather than part of it.
|
|
||||||
let indented = self.indented(run_end, end);
|
let indented = self.indented(run_end, end);
|
||||||
self.emit(indented, end, Kind::Metadata);
|
self.emit(indented, end, Kind::Metadata);
|
||||||
Some(self.code[run_start..run_end].to_vec())
|
Some(self.code[run_start..run_end].to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this line closes a fence opened by `open`: the same
|
|
||||||
/// character, at least as many of them, and nothing else on the line.
|
|
||||||
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
|
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
|
||||||
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -217,12 +178,8 @@ impl MarkdownScanner {
|
|||||||
self.indented(run_end, end) == end
|
self.indented(run_end, end) == end
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One ordinary line: what its opening characters make it, and then its prose.
|
|
||||||
fn structure(&mut self, start: usize, end: usize) {
|
fn structure(&mut self, start: usize, end: usize) {
|
||||||
let mut at = start;
|
let mut at = start;
|
||||||
// Quote markers come before everything else and can be several
|
|
||||||
// deep, and what follows one is an ordinary line again -- a heading
|
|
||||||
// inside a quote is still a heading.
|
|
||||||
while at < end && self.code[at] == '>' {
|
while at < end && self.code[at] == '>' {
|
||||||
at += 1;
|
at += 1;
|
||||||
self.emit(at - 1, at, Kind::Mark);
|
self.emit(at - 1, at, Kind::Mark);
|
||||||
@@ -238,8 +195,6 @@ impl MarkdownScanner {
|
|||||||
self.inline(text_start, end);
|
self.inline(text_start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `#` to `######` and a space. Without the space it is a word
|
|
||||||
/// beginning with a hash.
|
|
||||||
fn heading(&mut self, start: usize, end: usize) -> bool {
|
fn heading(&mut self, start: usize, end: usize) -> bool {
|
||||||
let mut at = start;
|
let mut at = start;
|
||||||
while at < end && self.code[at] == '#' {
|
while at < end && self.code[at] == '#' {
|
||||||
@@ -256,15 +211,13 @@ impl MarkdownScanner {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A line made of one repeated rule character and nothing else.
|
|
||||||
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
||||||
let marker = self.code[start];
|
let marker = self.code[start];
|
||||||
if !RULE_MARKERS.contains(marker) {
|
if !RULE_MARKERS.contains(marker) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let mut seen = 0usize;
|
let mut seen = 0usize;
|
||||||
for at in start..end {
|
for &c in &self.code[start..end] {
|
||||||
let c = self.code[at];
|
|
||||||
if c == marker {
|
if c == marker {
|
||||||
seen += 1;
|
seen += 1;
|
||||||
} else if !c.is_whitespace() {
|
} else if !c.is_whitespace() {
|
||||||
@@ -278,8 +231,6 @@ impl MarkdownScanner {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws a list marker if the line opens with one, and answers where
|
|
||||||
/// the item's text starts.
|
|
||||||
fn bullet(&mut self, start: usize, end: usize) -> usize {
|
fn bullet(&mut self, start: usize, end: usize) -> usize {
|
||||||
let marker = self.code[start];
|
let marker = self.code[start];
|
||||||
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
|
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
|
||||||
@@ -305,16 +256,11 @@ impl MarkdownScanner {
|
|||||||
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
|
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The inline forms, left to right. Every branch answers a position
|
|
||||||
/// strictly after `start` of its call, so this terminates.
|
|
||||||
fn inline(&mut self, start: usize, end: usize) {
|
fn inline(&mut self, start: usize, end: usize) {
|
||||||
let mut at = start;
|
let mut at = start;
|
||||||
while at < end {
|
while at < end {
|
||||||
let c = self.code[at];
|
let c = self.code[at];
|
||||||
at = if c == '\\' {
|
at = if c == '\\' {
|
||||||
// A backslash takes the character after it out of the
|
|
||||||
// running entirely, which is how `\*` stays an asterisk
|
|
||||||
// rather than opening emphasis.
|
|
||||||
at + 2
|
at + 2
|
||||||
} else if c == '`' {
|
} else if c == '`' {
|
||||||
self.code_span(at, end)
|
self.code_span(at, end)
|
||||||
@@ -332,7 +278,6 @@ impl MarkdownScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
|
|
||||||
fn code_span(&mut self, start: usize, end: usize) -> usize {
|
fn code_span(&mut self, start: usize, end: usize) -> usize {
|
||||||
let mut open = start;
|
let mut open = start;
|
||||||
while open < end && self.code[open] == '`' {
|
while open < end && self.code[open] == '`' {
|
||||||
@@ -355,11 +300,9 @@ impl MarkdownScanner {
|
|||||||
}
|
}
|
||||||
at = close;
|
at = close;
|
||||||
}
|
}
|
||||||
// Nothing closes it on this line, so those were ordinary backticks.
|
|
||||||
open
|
open
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[text](destination)`, and the same with a leading `!` for an image.
|
|
||||||
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
||||||
let mut depth = 0i32;
|
let mut depth = 0i32;
|
||||||
let mut close = bracket;
|
let mut close = bracket;
|
||||||
@@ -398,8 +341,6 @@ impl MarkdownScanner {
|
|||||||
paren + 1
|
paren + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `<https://example.com>` and `<name@example.com>`, drawn as the
|
|
||||||
/// destination they are.
|
|
||||||
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
||||||
let mut at = start + 1;
|
let mut at = start + 1;
|
||||||
let mut addressed = false;
|
let mut addressed = false;
|
||||||
@@ -423,8 +364,6 @@ impl MarkdownScanner {
|
|||||||
start + 1
|
start + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bare `scheme://...` written in prose, or `None` if one does not
|
|
||||||
/// start here.
|
|
||||||
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
|
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
|
||||||
if start > 0 && is_word(self.code[start - 1]) {
|
if start > 0 && is_word(self.code[start - 1]) {
|
||||||
return None;
|
return None;
|
||||||
@@ -466,8 +405,6 @@ impl MarkdownScanner {
|
|||||||
Some(at)
|
Some(at)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
|
|
||||||
/// all.
|
|
||||||
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
||||||
let marker = self.code[start];
|
let marker = self.code[start];
|
||||||
let mut open = start;
|
let mut open = start;
|
||||||
@@ -1,24 +1,3 @@
|
|||||||
//! `code` read once, left to right, into the spans that carry a colour.
|
|
||||||
//! Ported from `app/.../Highlighter.kt`.
|
|
||||||
//!
|
|
||||||
//! One pass with a small state -- in a comment, in a string, or in ordinary
|
|
||||||
//! code -- rather than a locator per token kind over the whole text, which
|
|
||||||
//! is what the library this replaced did and is why it found comments
|
|
||||||
//! before it knew the language: a `#` inside a shell string, a `//` inside
|
|
||||||
//! a URL and a block-comment opener inside a shell glob each commented out
|
|
||||||
//! the rest of a line that was nothing of the sort.
|
|
||||||
//!
|
|
||||||
//! Every span is produced by advancing an index forward, so the result is
|
|
||||||
//! ordered, non-overlapping and inside the code by construction. Nothing
|
|
||||||
//! here panics: an unterminated string or comment runs to the end of the
|
|
||||||
//! code, which is also what it looks like while a fence is still being
|
|
||||||
//! written.
|
|
||||||
//!
|
|
||||||
//! **Indices are char offsets, not byte offsets** -- the scanner works over
|
|
||||||
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
|
|
||||||
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
|
|
||||||
//! back into the text it covers.
|
|
||||||
|
|
||||||
pub mod languages;
|
pub mod languages;
|
||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
|
|
||||||
@@ -26,7 +5,6 @@ pub use languages::{
|
|||||||
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
|
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What a span of code is, in the terms a palette has a colour for.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum Kind {
|
pub enum Kind {
|
||||||
Keyword,
|
Keyword,
|
||||||
@@ -38,7 +16,6 @@ pub enum Kind {
|
|||||||
Mark,
|
Mark,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Span {
|
pub struct Span {
|
||||||
pub start: usize,
|
pub start: usize,
|
||||||
@@ -70,8 +47,6 @@ pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
|
|||||||
Scanner::new(code, rules).run()
|
Scanner::new(code, rules).run()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Characters coloured as punctuation, and as marks. Both sets are the ones
|
|
||||||
/// the library this replaced used.
|
|
||||||
const PUNCTUATION: &str = ",.:;";
|
const PUNCTUATION: &str = ",.:;";
|
||||||
const MARKS: &str = "()={}<>-+[]|&";
|
const MARKS: &str = "()={}<>-+[]|&";
|
||||||
|
|
||||||
@@ -94,8 +69,6 @@ impl<'a> Scanner<'a> {
|
|||||||
|
|
||||||
fn run(mut self) -> Vec<Span> {
|
fn run(mut self) -> Vec<Span> {
|
||||||
while self.at < self.code.len() {
|
while self.at < self.code.len() {
|
||||||
// Every branch that answers true has advanced `self.at`, so
|
|
||||||
// this terminates.
|
|
||||||
let consumed = self.block_comment()
|
let consumed = self.block_comment()
|
||||||
|| self.line_comment()
|
|| self.line_comment()
|
||||||
|| self.raw_string()
|
|| self.raw_string()
|
||||||
@@ -126,15 +99,12 @@ impl<'a> Scanner<'a> {
|
|||||||
starts_with_at(&self.code, self.at, token)
|
starts_with_at(&self.code, self.at, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a line comment token here opens one; see
|
|
||||||
/// [`Rules::line_comments_at_word_start`].
|
|
||||||
fn at_word_start(&self) -> bool {
|
fn at_word_start(&self) -> bool {
|
||||||
self.at == 0
|
self.at == 0
|
||||||
|| self.code[self.at - 1].is_whitespace()
|
|| self.code[self.at - 1].is_whitespace()
|
||||||
|| ";|&(".contains(self.code[self.at - 1])
|
|| ";|&(".contains(self.code[self.at - 1])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether only whitespace stands between the start of this line and here.
|
|
||||||
fn at_line_start(&self) -> bool {
|
fn at_line_start(&self) -> bool {
|
||||||
let mut back = self.at as isize - 1;
|
let mut back = self.at as isize - 1;
|
||||||
while back >= 0 && self.code[back as usize] != '\n' {
|
while back >= 0 && self.code[back as usize] != '\n' {
|
||||||
@@ -152,8 +122,6 @@ impl<'a> Scanner<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// From an open bracket through the one that matches it, or to the end
|
|
||||||
/// if none does.
|
|
||||||
fn advance_to_matching_bracket(&mut self) {
|
fn advance_to_matching_bracket(&mut self) {
|
||||||
let mut depth = 0i32;
|
let mut depth = 0i32;
|
||||||
while self.at < self.code.len() {
|
while self.at < self.code.len() {
|
||||||
@@ -180,9 +148,6 @@ impl<'a> Scanner<'a> {
|
|||||||
self.at += comment.open.chars().count();
|
self.at += comment.open.chars().count();
|
||||||
let mut depth = 1i32;
|
let mut depth = 1i32;
|
||||||
while self.at < self.code.len() && depth > 0 {
|
while self.at < self.code.len() && depth > 0 {
|
||||||
// The closer is tried first so that a language whose two
|
|
||||||
// delimiters are the same string -- CoffeeScript's `###` --
|
|
||||||
// closes rather than nesting forever.
|
|
||||||
if self.starts(comment.close) {
|
if self.starts(comment.close) {
|
||||||
depth -= 1;
|
depth -= 1;
|
||||||
self.at += comment.close.chars().count();
|
self.at += comment.close.chars().count();
|
||||||
@@ -210,7 +175,6 @@ impl<'a> Scanner<'a> {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
|
|
||||||
fn raw_string(&mut self) -> bool {
|
fn raw_string(&mut self) -> bool {
|
||||||
if !self.rules.raw_strings {
|
if !self.rules.raw_strings {
|
||||||
return false;
|
return false;
|
||||||
@@ -267,8 +231,6 @@ impl<'a> Scanner<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn string(&mut self) -> bool {
|
fn string(&mut self) -> bool {
|
||||||
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
|
|
||||||
// than an empty string followed by a quote.
|
|
||||||
let mut quote: Option<Quote> = None;
|
let mut quote: Option<Quote> = None;
|
||||||
for candidate in &self.rules.quotes {
|
for candidate in &self.rules.quotes {
|
||||||
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
||||||
@@ -346,9 +308,6 @@ impl<'a> Scanner<'a> {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A number is a run starting with a digit and carrying on through
|
|
||||||
/// letters, digits, `_` and `.` -- which covers `0xFF`, `1_000`, `1u32`
|
|
||||||
/// and `3.14` without a grammar for any of them.
|
|
||||||
fn number(&mut self) -> bool {
|
fn number(&mut self) -> bool {
|
||||||
if !self.code[self.at].is_ascii_digit() {
|
if !self.code[self.at].is_ascii_digit() {
|
||||||
return false;
|
return false;
|
||||||
@@ -403,7 +362,6 @@ fn is_word_part(c: char) -> bool {
|
|||||||
c.is_alphanumeric() || c == '_'
|
c.is_alphanumeric() || c == '_'
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `code[at..]` starts with `token`, both read as chars.
|
|
||||||
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
||||||
let token: Vec<char> = token.chars().collect();
|
let token: Vec<char> = token.chars().collect();
|
||||||
if at + token.len() > code.len() {
|
if at + token.len() > code.len() {
|
||||||
@@ -412,8 +370,6 @@ fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
|||||||
code[at..at + token.len()] == token[..]
|
code[at..at + token.len()] == token[..]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The first index at or after `from` where `code` contains `needle`, or
|
|
||||||
/// `None`.
|
|
||||||
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
|
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
|
||||||
if needle.is_empty() || from > code.len() {
|
if needle.is_empty() || from > code.len() {
|
||||||
return None;
|
return None;
|
||||||
@@ -640,10 +596,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The scanner must never panic and must never answer a span the code
|
|
||||||
/// does not contain: the library this replaced answered a reversed
|
|
||||||
/// range here, which crashed a card, and a fence still being written is
|
|
||||||
/// an unterminated string or comment on every keystroke.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
||||||
let nasty = [
|
let nasty = [
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
//! The app's pure logic, shared between the server and any Rust client --
|
|
||||||
//! see `docs/CLIENT_CORE.md` for what lives here and what does
|
|
||||||
//! not yet.
|
|
||||||
|
|
||||||
pub mod ansi;
|
pub mod ansi;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod durations;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
|
pub mod log_ring;
|
||||||
|
pub mod markdown_blocks;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
|
pub mod text_cap;
|
||||||
|
pub mod tool_summary;
|
||||||
pub mod transcript_cache;
|
pub mod transcript_cache;
|
||||||
pub mod transcript_fold;
|
pub mod transcript_fold;
|
||||||
pub mod transcript_source;
|
pub mod transcript_source;
|
||||||
@@ -1,27 +1,17 @@
|
|||||||
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
|
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
|
||||||
//! places, never both" describes. Ported from the parsing half of
|
//! places, never both" describes. Ported from the parsing half of
|
||||||
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
|
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
|
||||||
//! ([`crate::sse`]) and the wire shape ([`SessionNotification`],
|
//! ([`crate::client::sse`]) and the wire shape ([`SessionNotification`],
|
||||||
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
||||||
//! `Notification`/`NotificationKind`).
|
//! `Notification`/`NotificationKind`).
|
||||||
//!
|
|
||||||
//! What is deliberately **not** here, because it is a decision rather than
|
|
||||||
//! logic: whether a given notification is shown at all (the session on
|
|
||||||
//! screen gets nothing), handed to the app as a banner, or posted to the
|
|
||||||
//! platform's own notification drawer. That three-way choice reads
|
|
||||||
//! process-wide state (what screen is open, whether the app is in front)
|
|
||||||
//! that has no meaning to a pure crate with no UI and no Android in it --
|
|
||||||
//! see `android-shell` for where it lives for this port.
|
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::api::{ApiError, Transport};
|
use crate::client::api::{ApiError, Transport};
|
||||||
use crate::sse::SseReader;
|
use crate::client::sse::SseReader;
|
||||||
|
|
||||||
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
|
|
||||||
/// `Notification` field for field.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SessionNotification {
|
pub struct SessionNotification {
|
||||||
@@ -32,9 +22,6 @@ pub struct SessionNotification {
|
|||||||
pub at: f64,
|
pub at: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized
|
|
||||||
/// the same way, so this deserializes the wire's `"awaitingInput"` /
|
|
||||||
/// `"finished"` directly rather than through a string match.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum NotificationKind {
|
pub enum NotificationKind {
|
||||||
@@ -93,7 +80,7 @@ pub fn follow_notifications(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::{Body, RawResponse};
|
use crate::client::api::{Body, RawResponse};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
struct FixtureTransport {
|
struct FixtureTransport {
|
||||||
@@ -1,15 +1,3 @@
|
|||||||
//! Server-sent-events framing, ported from `app/.../Sse.kt`: `data:` and
|
|
||||||
//! `event:` lines accumulate until a blank line ends the frame, comments
|
|
||||||
//! start with `:`, and a frame is either named with no payload or a payload
|
|
||||||
//! with no name.
|
|
||||||
//!
|
|
||||||
//! Pure and line-at-a-time, unlike the Kotlin original which also owned the
|
|
||||||
//! socket: `server/routes.rs`'s SSE bodies are one event per line, so a
|
|
||||||
//! caller here feeds lines from wherever they came from (a real connection,
|
|
||||||
//! a test fixture) and gets frames back with no I/O of its own -- which is
|
|
||||||
//! what lets this be tested with no server, per RUST.md's "pure logic
|
|
||||||
//! first" for this crate.
|
|
||||||
|
|
||||||
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
|
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Frame {
|
pub struct Frame {
|
||||||
@@ -17,10 +5,6 @@ pub struct Frame {
|
|||||||
pub data: String,
|
pub data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accumulates lines into [`Frame`]s. One instance per connection --
|
|
||||||
/// `feed_line` is called for every line the transport reads (with line
|
|
||||||
/// endings already stripped), and answers a frame when a blank line closes
|
|
||||||
/// one.
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct SseReader {
|
pub struct SseReader {
|
||||||
data: String,
|
data: String,
|
||||||
@@ -32,8 +16,6 @@ impl SseReader {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feeds one line (no trailing `\n`). Answers the frame this line
|
|
||||||
/// completed, if any.
|
|
||||||
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
|
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
if self.name.is_some() || !self.data.is_empty() {
|
if self.name.is_some() || !self.data.is_empty() {
|
||||||
@@ -50,7 +32,6 @@ impl SseReader {
|
|||||||
} else if let Some(rest) = line.strip_prefix("event:") {
|
} else if let Some(rest) = line.strip_prefix("event:") {
|
||||||
self.name = Some(rest.trim().to_string());
|
self.name = Some(rest.trim().to_string());
|
||||||
}
|
}
|
||||||
// `id:`, comments -- nothing to do.
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +1,17 @@
|
|||||||
//! This phone's copy of the transcripts it has already been sent, so
|
|
||||||
//! reopening a session does not download it again. Ported from
|
|
||||||
//! `app/.../TranscriptCache.kt`; see `docs/TRANSCRIPT_CACHE.md`
|
|
||||||
//! for the design and `docs/CLIENT_CORE.md` for how this file corresponds to it.
|
|
||||||
//!
|
|
||||||
//! What is stored is the server's own JSON for one event per line, in
|
|
||||||
//! transcript order. Reading the cache means running the same [`seq_of`]
|
|
||||||
//! the network path runs, so a cached transcript and a fetched one cannot
|
|
||||||
//! draw differently, and an event type this build does not know keeps
|
|
||||||
//! every field it arrived with for the build that will. Rows are
|
|
||||||
//! deliberately *not* what is stored: a row is a rendering, and a cache of
|
|
||||||
//! rows would need throwing away on every update that touched the fold.
|
|
||||||
//!
|
|
||||||
//! Four rules run through all of it:
|
|
||||||
//! 1. what is on screen is what the server's transcript says, in order,
|
|
||||||
//! with nothing missing -- the cache is a copy and is never inferred,
|
|
||||||
//! folded or edited here;
|
|
||||||
//! 2. a cached line is never ahead of the live cursor, and the cursor never
|
|
||||||
//! ahead of the cache;
|
|
||||||
//! 3. the cache is never load-bearing -- missing, evicted, damaged or
|
|
||||||
//! unwritable all degrade to a cold open, never to a blank or a wrong
|
|
||||||
//! screen;
|
|
||||||
//! 4. a line already on the phone is not fetched again.
|
|
||||||
//!
|
|
||||||
//! No JSON parser here: what it needs off a line is the sequence number and
|
|
||||||
//! whether the line is a streamed delta, both read with a regex-free scan
|
|
||||||
//! (see [`seq_of`] and [`is_delta`]). A line it cannot read that way is
|
|
||||||
//! treated as damage.
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// How much of this phone's cache directory all of one server's transcripts
|
|
||||||
/// may take. A dozen of the largest transcripts seen in the dev VM (21 MB
|
|
||||||
/// for 24,000 events) and a small fraction of a phone. A number to revisit
|
|
||||||
/// against real use rather than a measurement of anything.
|
|
||||||
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
|
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
|
||||||
|
|
||||||
/// What the newest cached line says, which is what the probe checks against
|
|
||||||
/// the server.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CachedTail {
|
pub struct CachedTail {
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
pub line: String,
|
pub line: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This phone's cache root for one server, holding one directory per session.
|
|
||||||
pub struct TranscriptCache {
|
pub struct TranscriptCache {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
||||||
@@ -68,8 +32,6 @@ impl TranscriptCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The cache for one session, whether or not anything has been stored
|
|
||||||
/// for it yet.
|
|
||||||
pub fn session(&self, id: &str) -> SessionCache {
|
pub fn session(&self, id: &str) -> SessionCache {
|
||||||
SessionCache::new(self.root.join(id), self.warn.clone())
|
SessionCache::new(self.root.join(id), self.warn.clone())
|
||||||
}
|
}
|
||||||
@@ -164,36 +126,10 @@ fn dir_size(path: &Path) -> u64 {
|
|||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One session's cached lines, as a directory of chunks.
|
|
||||||
///
|
|
||||||
/// A chunk is a set of lines *and a claim about what they cover*, and the
|
|
||||||
/// two are not the same thing: a coalesced page joins each run of streamed
|
|
||||||
/// deltas into one event carrying the seq of the run's oldest delta, so a
|
|
||||||
/// page whose newest event is seq 1,200 may cover everything up to the
|
|
||||||
/// 1,650 it was fetched with, and nothing in the lines says so. So coverage
|
|
||||||
/// is the half-open range in the file's name:
|
|
||||||
/// `<first>-<end>.rows.jsonl` (a coalesced page; `end` is the `before` it
|
|
||||||
/// was fetched with) or `<first>-<end>.raw.jsonl` (an uncoalesced page, or a
|
|
||||||
/// closed live run); `<first>-open.raw.jsonl` is the live run, whose end is
|
|
||||||
/// its last line's seq + 1.
|
|
||||||
///
|
|
||||||
/// Two chunks are adjacent when one's `end` is the other's `first`. Only
|
/// Two chunks are adjacent when one's `end` is the other's `first`. Only
|
||||||
/// the contiguous run ending at the newest chunk -- the **suffix** -- is
|
/// the contiguous run ending at the newest chunk -- the **suffix** -- is
|
||||||
/// ever served: chunks behind a gap are kept, because the gap is usually
|
/// ever served: chunks behind a gap are kept, because the gap is usually
|
||||||
/// closed by paging back through it, but nothing is served across one.
|
/// closed by paging back through it, but nothing is served across one.
|
||||||
///
|
|
||||||
/// **The newest chunk is always raw**, which is what makes the stream
|
|
||||||
/// cursor and the probe well defined.
|
|
||||||
///
|
|
||||||
/// Nothing here is load-bearing. Every operation that touches the disk
|
|
||||||
/// answers as though the cache were empty when it cannot, and a write
|
|
||||||
/// failure disables writing for the rest of this instance's life so that a
|
|
||||||
/// full disk costs one log line rather than one per delta.
|
|
||||||
///
|
|
||||||
/// A `Mutex` around the writer state stands in for Kotlin's `@Synchronized`:
|
|
||||||
/// the stream appends live events from its own thread while a reader
|
|
||||||
/// scrolling back reads pages from another, and this is what keeps the open
|
|
||||||
/// chunk's name, its end and its writer from being read half-rotated.
|
|
||||||
pub struct SessionCache {
|
pub struct SessionCache {
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
||||||
@@ -202,8 +138,6 @@ pub struct SessionCache {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct WriterState {
|
struct WriterState {
|
||||||
/// Set by the first write that fails: a second would fail the same way,
|
|
||||||
/// once per delta.
|
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
writer: Option<fs::File>,
|
writer: Option<fs::File>,
|
||||||
open_file: Option<PathBuf>,
|
open_file: Option<PathBuf>,
|
||||||
@@ -238,7 +172,6 @@ impl SessionCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The newest `limit` lines of the suffix, oldest first -- the opening window.
|
|
||||||
pub fn newest(&self, limit: usize) -> Vec<String> {
|
pub fn newest(&self, limit: usize) -> Vec<String> {
|
||||||
self.guard(Vec::new(), |this, state| {
|
self.guard(Vec::new(), |this, state| {
|
||||||
let mut taken: VecDeque<String> = VecDeque::new();
|
let mut taken: VecDeque<String> = VecDeque::new();
|
||||||
@@ -262,11 +195,6 @@ impl SessionCache {
|
|||||||
/// below `before` -- and means the server has to be asked. Deliberately
|
/// below `before` -- and means the server has to be asked. Deliberately
|
||||||
/// not an empty list: an empty page is how the screen is told it has
|
/// not an empty list: an empty page is how the screen is told it has
|
||||||
/// reached the start of the conversation.
|
/// reached the start of the conversation.
|
||||||
///
|
|
||||||
/// With `rows` the count is rows rather than lines, mirroring the
|
|
||||||
/// server's `parse_coalesced`. The deltas are not joined here -- the
|
|
||||||
/// fold does that, and the joined row keeps the seq of its first delta
|
|
||||||
/// either way.
|
|
||||||
pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> {
|
pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> {
|
||||||
self.guard(None, |this, state| {
|
self.guard(None, |this, state| {
|
||||||
let suffix = this.suffix(state)?;
|
let suffix = this.suffix(state)?;
|
||||||
@@ -291,18 +219,12 @@ impl SessionCache {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.each_line(state, chunk, |line| {
|
this.each_line(state, chunk, |line| {
|
||||||
// The page is what is *before* the cursor; the rows at
|
|
||||||
// or above it are already on screen.
|
|
||||||
let seq = seq_of(line).expect("chunk lines are checked in each_line");
|
let seq = seq_of(line).expect("chunk lines are checked in each_line");
|
||||||
if seq >= before {
|
if seq >= before {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if rows {
|
if rows {
|
||||||
let delta = is_delta(line);
|
let delta = is_delta(line);
|
||||||
// Stop only between rows: a delta continuing the
|
|
||||||
// run being gathered is part of a row already
|
|
||||||
// counted, and breaking on it would drop the half
|
|
||||||
// of that row already taken.
|
|
||||||
if counted >= limit && !(delta && in_run) {
|
if counted >= limit && !(delta && in_run) {
|
||||||
wanting = false;
|
wanting = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -338,9 +260,6 @@ impl SessionCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stores a fetched page covering `[first, end)`; `false` when it was
|
|
||||||
/// not stored.
|
|
||||||
///
|
|
||||||
/// Refused when it overlaps a chunk already here, because there is no
|
/// Refused when it overlaps a chunk already here, because there is no
|
||||||
/// clean cut: a coalesced event cannot be split at a seq inside its own
|
/// clean cut: a coalesced event cannot be split at a seq inside its own
|
||||||
/// delta run. The caller keeps that from arising by bounding what it
|
/// delta run. The caller keeps that from arising by bounding what it
|
||||||
@@ -377,13 +296,6 @@ impl SessionCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appends one live event, which is also how a freshly fetched opening
|
|
||||||
/// window is stored.
|
|
||||||
///
|
|
||||||
/// A seq equal to the open chunk's end extends it. A larger one is a
|
|
||||||
/// gap -- which is what a `reset` looks like from here -- and closes
|
|
||||||
/// the open chunk under the end it turned out to have. A smaller one is
|
|
||||||
/// already covered and is ignored; the SSE contract is `seq > after`.
|
|
||||||
pub fn append(&self, line: &str, seq: u64) {
|
pub fn append(&self, line: &str, seq: u64) {
|
||||||
self.guard((), |this, state| {
|
self.guard((), |this, state| {
|
||||||
if state.disabled {
|
if state.disabled {
|
||||||
@@ -392,13 +304,6 @@ impl SessionCache {
|
|||||||
let Some(writer) = this.writer_for(state, seq)? else {
|
let Some(writer) = this.writer_for(state, seq)? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
// Written as it arrived. A newline inside it would split one
|
|
||||||
// event into two unreadable halves. No source here can produce
|
|
||||||
// one -- an SSE `data:` field cannot hold a raw newline, and a
|
|
||||||
// fetched line is one element of a compact JSON array -- but
|
|
||||||
// that is a fact about the *server's* serializer rather than
|
|
||||||
// anything this file controls, so it is checked rather than
|
|
||||||
// trusted.
|
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
!line.contains('\n'),
|
!line.contains('\n'),
|
||||||
"a cached transcript line must be one line: {line}"
|
"a cached transcript line must be one line: {line}"
|
||||||
@@ -411,7 +316,6 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flushes what [`Self::append`] has buffered.
|
|
||||||
pub fn flush(&self) {
|
pub fn flush(&self) {
|
||||||
self.guard((), |_this, state| {
|
self.guard((), |_this, state| {
|
||||||
if let Some(writer) = state.writer.as_mut() {
|
if let Some(writer) = state.writer.as_mut() {
|
||||||
@@ -422,12 +326,10 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`Self::purge`] would discard, for the reload row in session settings.
|
|
||||||
pub fn bytes(&self) -> u64 {
|
pub fn bytes(&self) -> u64 {
|
||||||
self.guard(0, |this, _state| Ok(dir_size(&this.dir)))
|
self.guard(0, |this, _state| Ok(dir_size(&this.dir)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks this session as visited, which is what eviction ranks by.
|
|
||||||
pub fn touch(&self) {
|
pub fn touch(&self) {
|
||||||
self.guard((), |this, _state| {
|
self.guard((), |this, _state| {
|
||||||
if this.dir.is_dir() {
|
if this.dir.is_dir() {
|
||||||
@@ -448,8 +350,6 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- chunks ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Every chunk on disk, oldest first. A name this does not recognise is
|
/// Every chunk on disk, oldest first. A name this does not recognise is
|
||||||
/// not ours and is ignored. Recomputed per operation rather than kept:
|
/// not ours and is ignored. Recomputed per operation rather than kept:
|
||||||
/// another operation may have changed the directory.
|
/// another operation may have changed the directory.
|
||||||
@@ -475,9 +375,6 @@ impl SessionCache {
|
|||||||
} else {
|
} else {
|
||||||
end_str.parse::<u64>().ok()
|
end_str.parse::<u64>().ok()
|
||||||
};
|
};
|
||||||
// A chunk covering nothing is one that was created and never
|
|
||||||
// written to -- an append whose very first write failed. It
|
|
||||||
// says nothing, so it is not a chunk.
|
|
||||||
if let Some(end) = end
|
if let Some(end) = end
|
||||||
&& end > first
|
&& end > first
|
||||||
{
|
{
|
||||||
@@ -494,13 +391,6 @@ impl SessionCache {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The open chunk's end: its last line's seq plus one, or the in-memory
|
|
||||||
/// end while this instance is the one writing it.
|
|
||||||
///
|
|
||||||
/// An open chunk whose last line cannot be read is this app having died
|
|
||||||
/// mid-write. That line is dropped and the file truncated to the last
|
|
||||||
/// good one, which is the one place damage is repaired rather than
|
|
||||||
/// discarded.
|
|
||||||
fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> {
|
fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> {
|
||||||
if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
|
if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
|
||||||
return Some(state.open_end);
|
return Some(state.open_end);
|
||||||
@@ -516,12 +406,6 @@ impl SessionCache {
|
|||||||
Some(end)
|
Some(end)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The contiguous run of adjacent chunks ending at the newest one,
|
|
||||||
/// oldest first.
|
|
||||||
///
|
|
||||||
/// A newest chunk that is not raw cannot happen while this code is the
|
|
||||||
/// only writer, and means the directory is not to be trusted -- so the
|
|
||||||
/// session is discarded.
|
|
||||||
fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> {
|
fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> {
|
||||||
let all = self.chunks(state)?;
|
let all = self.chunks(state)?;
|
||||||
let Some(newest) = all.last() else {
|
let Some(newest) = all.last() else {
|
||||||
@@ -540,8 +424,6 @@ impl SessionCache {
|
|||||||
Ok(run.into_iter().collect())
|
Ok(run.into_iter().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Each line of `chunk`, newest first, until `take` says stop.
|
|
||||||
///
|
|
||||||
/// Damage anywhere but at the tail of the open chunk was not written by
|
/// Damage anywhere but at the tail of the open chunk was not written by
|
||||||
/// this code, and there is no honest way to say what a chunk covers
|
/// this code, and there is no honest way to say what a chunk covers
|
||||||
/// with a line of it unreadable -- so it is treated as damage rather
|
/// with a line of it unreadable -- so it is treated as damage rather
|
||||||
@@ -565,10 +447,6 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- writing -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// The writer for the chunk `seq` belongs in, opening or rotating one
|
|
||||||
/// as it has to.
|
|
||||||
fn writer_for<'s>(
|
fn writer_for<'s>(
|
||||||
&self,
|
&self,
|
||||||
state: &'s mut WriterState,
|
state: &'s mut WriterState,
|
||||||
@@ -581,14 +459,10 @@ impl SessionCache {
|
|||||||
if seq < state.open_end {
|
if seq < state.open_end {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// A gap: what this instance has written covers up to `open_end`,
|
|
||||||
// and that is the name the chunk gets before a new one starts
|
|
||||||
// at the arriving seq.
|
|
||||||
let end = state.open_end;
|
let end = state.open_end;
|
||||||
self.close_open_chunk(state, end);
|
self.close_open_chunk(state, end);
|
||||||
}
|
}
|
||||||
fs::create_dir_all(&self.dir)?;
|
fs::create_dir_all(&self.dir)?;
|
||||||
// An open chunk left by an earlier instance, or by an earlier screen.
|
|
||||||
let existing = self.chunks(state)?.into_iter().rfind(|c| c.open);
|
let existing = self.chunks(state)?.into_iter().rfind(|c| c.open);
|
||||||
if let Some(existing) = existing {
|
if let Some(existing) = existing {
|
||||||
if seq < existing.end {
|
if seq < existing.end {
|
||||||
@@ -632,8 +506,6 @@ impl SessionCache {
|
|||||||
Ok(state.writer.as_mut())
|
Ok(state.writer.as_mut())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renames the open chunk to the range it turned out to cover, so it
|
|
||||||
/// stops being open.
|
|
||||||
fn close_open_chunk(&self, state: &mut WriterState, end: u64) {
|
fn close_open_chunk(&self, state: &mut WriterState, end: u64) {
|
||||||
let file = state.open_file.clone();
|
let file = state.open_file.clone();
|
||||||
close_writer(state);
|
close_writer(state);
|
||||||
@@ -647,31 +519,17 @@ impl SessionCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- failure -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Runs `body`, answering `if_broken` when the directory cannot give a
|
|
||||||
/// real answer. None of this is reported on screen: every read here has
|
|
||||||
/// a network path beside it producing the same result, and the reader
|
|
||||||
/// has nothing to do about it. Damage discards this session's cache,
|
|
||||||
/// which makes the next open an ordinary cold one.
|
|
||||||
fn guard<T>(
|
fn guard<T>(
|
||||||
&self,
|
&self,
|
||||||
if_broken: T,
|
if_broken: T,
|
||||||
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
|
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
|
||||||
) -> T {
|
) -> T {
|
||||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
// A disk that refused once will refuse again, once per delta, so
|
|
||||||
// the first refusal is also the last.
|
|
||||||
if state.disabled {
|
if state.disabled {
|
||||||
return if_broken;
|
return if_broken;
|
||||||
}
|
}
|
||||||
DAMAGED.with(|cell| *cell.borrow_mut() = None);
|
DAMAGED.with(|cell| *cell.borrow_mut() = None);
|
||||||
let result = body(self, &mut state);
|
let result = body(self, &mut state);
|
||||||
// Damage takes priority over whatever `body` returned, `Ok` or
|
|
||||||
// `Err`: `suffix` signals it by returning `Err(damaged(..))`
|
|
||||||
// precisely so this check catches it before the branch below
|
|
||||||
// mistakes it for a real I/O failure and disables the whole cache
|
|
||||||
// over one corrupt session.
|
|
||||||
if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) {
|
if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) {
|
||||||
(self.warn)(&format!(
|
(self.warn)(&format!(
|
||||||
"transcript cache damaged at {}; discarding {}",
|
"transcript cache damaged at {}; discarding {}",
|
||||||
@@ -695,11 +553,6 @@ impl SessionCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// How [`SessionCache::each_line`] reports a line it cannot make sense
|
|
||||||
/// of back up to [`SessionCache::guard`], since the callback it hands
|
|
||||||
/// `each_line_backwards` cannot itself return a `Result`. Thread-local
|
|
||||||
/// rather than a field: the guard that reads it always runs on the same
|
|
||||||
/// call stack that could have set it, one `guard` call at a time.
|
|
||||||
static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,8 +580,6 @@ fn rename_chunk(file: &Path, dir: &Path, first: u64, end: u64) {
|
|||||||
let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl")));
|
let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is
|
|
||||||
/// not ours.
|
|
||||||
fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
||||||
let rest = name.strip_suffix(".jsonl")?;
|
let rest = name.strip_suffix(".jsonl")?;
|
||||||
let (rest, kind) = rest.rsplit_once('.')?;
|
let (rest, kind) = rest.rsplit_once('.')?;
|
||||||
@@ -744,22 +595,14 @@ fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One line's sequence number, or `None` when the line is not one of ours.
|
/// One line's sequence number, or `None` when the line is not one of ours.
|
||||||
///
|
|
||||||
/// A hand-rolled scan rather than a JSON parse, so this module carries no
|
|
||||||
/// parser and stays testable with no server: the seq is the first field the
|
|
||||||
/// server writes, so the first match is the top-level one.
|
|
||||||
pub fn seq_of(line: &str) -> Option<u64> {
|
pub fn seq_of(line: &str) -> Option<u64> {
|
||||||
find_number_field(line, "seq")
|
find_number_field(line, "seq")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a line is one streamed piece of a reply, which is what makes a
|
|
||||||
/// run of them one row.
|
|
||||||
pub fn is_delta(line: &str) -> bool {
|
pub fn is_delta(line: &str) -> bool {
|
||||||
find_string_field(line, "type").as_deref() == Some("assistantText")
|
find_string_field(line, "type").as_deref() == Some("assistantText")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The value of `"key":N` (any amount of whitespace around the colon), or
|
|
||||||
/// `None`. Mirrors `Regex(""""seq"\s*:\s*(\d+)""")`'s first match.
|
|
||||||
fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
||||||
let pattern = format!("\"{key}\"");
|
let pattern = format!("\"{key}\"");
|
||||||
let at = line.find(&pattern)?;
|
let at = line.find(&pattern)?;
|
||||||
@@ -777,8 +620,6 @@ fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The value of `"key":"..."`, or `None`. Mirrors
|
|
||||||
/// `Regex(""""type"\s*:\s*"([^"]*)"""")`'s first match.
|
|
||||||
fn find_string_field(line: &str, key: &str) -> Option<String> {
|
fn find_string_field(line: &str, key: &str) -> Option<String> {
|
||||||
let pattern = format!("\"{key}\"");
|
let pattern = format!("\"{key}\"");
|
||||||
let at = line.find(&pattern)?;
|
let at = line.find(&pattern)?;
|
||||||
@@ -789,18 +630,11 @@ fn find_string_field(line: &str, key: &str) -> Option<String> {
|
|||||||
Some(after_quote[..end].to_string())
|
Some(after_quote[..end].to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much of a file is read at a time when walking it backwards. One
|
|
||||||
/// block covers a page of a transcript comfortably, and the walk stops as
|
|
||||||
/// soon as the caller has what it asked for.
|
|
||||||
const READ_BLOCK: usize = 64 * 1024;
|
const READ_BLOCK: usize = 64 * 1024;
|
||||||
|
|
||||||
/// Calls `on_line` with each non-blank line of `file`, **newest first**,
|
/// Calls `on_line` with each non-blank line of `file`, **newest first**,
|
||||||
/// along with the byte offset it starts at, until `on_line` answers false.
|
/// along with the byte offset it starts at, until `on_line` answers false.
|
||||||
///
|
///
|
||||||
/// Every question the cache is asked is about the newest end of a chunk,
|
|
||||||
/// and a live run reaches the size of the conversation, so reading forwards
|
|
||||||
/// means reading a transcript to answer with the last eighty lines of it.
|
|
||||||
///
|
|
||||||
/// Splitting on bytes is safe because the separator is `\n`, which cannot
|
/// Splitting on bytes is safe because the separator is `\n`, which cannot
|
||||||
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
|
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
|
||||||
/// missing file yields nothing.
|
/// missing file yields nothing.
|
||||||
@@ -823,7 +657,6 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
|||||||
}
|
}
|
||||||
let mut buffer = block;
|
let mut buffer = block;
|
||||||
buffer.extend_from_slice(&pending);
|
buffer.extend_from_slice(&pending);
|
||||||
// `buffer` is now `block` followed by `pending`; walk it backwards.
|
|
||||||
let mut line_end = buffer.len();
|
let mut line_end = buffer.len();
|
||||||
let mut at = buffer.len() as isize - 1;
|
let mut at = buffer.len() as isize - 1;
|
||||||
while at >= 0 {
|
while at >= 0 {
|
||||||
@@ -840,20 +673,12 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
|||||||
pending = buffer[..line_end].to_vec();
|
pending = buffer[..line_end].to_vec();
|
||||||
unread = start;
|
unread = start;
|
||||||
}
|
}
|
||||||
// The first line of a file has no newline before it to be found.
|
|
||||||
let first = String::from_utf8_lossy(&pending);
|
let first = String::from_utf8_lossy(&pending);
|
||||||
if !first.trim().is_empty() {
|
if !first.trim().is_empty() {
|
||||||
on_line(0, &first);
|
on_line(0, &first);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops a final line that is not one of ours, by truncating the file to
|
|
||||||
/// where it starts.
|
|
||||||
///
|
|
||||||
/// This app having died mid-write is the one kind of damage that is
|
|
||||||
/// repaired rather than discarded: the tail of an append-only file is the
|
|
||||||
/// only place a partial line can be. A second bad line is not this, and is
|
|
||||||
/// left for the read path to notice.
|
|
||||||
fn repair_tail(file: &Path) -> io::Result<()> {
|
fn repair_tail(file: &Path) -> io::Result<()> {
|
||||||
let mut truncate_to: Option<u64> = None;
|
let mut truncate_to: Option<u64> = None;
|
||||||
each_line_backwards(file, |offset, line| {
|
each_line_backwards(file, |offset, line| {
|
||||||
@@ -869,9 +694,6 @@ fn repair_tail(file: &Path) -> io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `body`, translating an I/O or permission failure into `if_broken`
|
|
||||||
/// and a warning -- the disk half of [`SessionCache::guard`], shared with
|
|
||||||
/// [`TranscriptCache`]'s own maintenance.
|
|
||||||
fn guard_io<T>(
|
fn guard_io<T>(
|
||||||
if_broken: T,
|
if_broken: T,
|
||||||
warn: &(impl Fn(&str) + ?Sized),
|
warn: &(impl Fn(&str) + ?Sized),
|
||||||
@@ -886,10 +708,6 @@ fn guard_io<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a path's modified time, without pulling in a crate for it: a single
|
|
||||||
/// `utimensat`-backed call would be one more platform-specific dependency
|
|
||||||
/// for one call site, so this touches the file instead, which every
|
|
||||||
/// filesystem this runs on updates the mtime for.
|
|
||||||
fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> {
|
fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
// Rewriting a marker file's contents (rather than the directory itself,
|
// Rewriting a marker file's contents (rather than the directory itself,
|
||||||
@@ -920,7 +738,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like `cache`, but also hands back the messages it warned with.
|
|
||||||
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
|
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
|
||||||
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
|
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
|
||||||
let said2 = said.clone();
|
let said2 = said.clone();
|
||||||
@@ -996,8 +813,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]);
|
assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]);
|
||||||
// More than there is is what there is, which is a short opening
|
|
||||||
// window and not a failure.
|
|
||||||
assert_eq!(session.newest(80).len(), 3);
|
assert_eq!(session.newest(80).len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1009,8 +824,6 @@ mod tests {
|
|||||||
for seq in 1..=3u64 {
|
for seq in 1..=3u64 {
|
||||||
session.append(&tool_line(seq), seq);
|
session.append(&tool_line(seq), seq);
|
||||||
}
|
}
|
||||||
// What a `reset` looks like from here: the next event is not the
|
|
||||||
// one after the last.
|
|
||||||
session.append(&tool_line(90), 90);
|
session.append(&tool_line(90), 90);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
@@ -1052,14 +865,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// Adjacent: its end is the open chunk's first.
|
|
||||||
let page: Vec<String> = (60..100u64).map(tool_line).collect();
|
let page: Vec<String> = (60..100u64).map(tool_line).collect();
|
||||||
assert!(session.store_page(&page, 60, 100, true));
|
assert!(session.store_page(&page, 60, 100, true));
|
||||||
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
|
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
|
||||||
assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60));
|
assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60));
|
||||||
|
|
||||||
// Behind a gap: kept on disk, because paging usually closes the
|
|
||||||
// gap, but never served across it.
|
|
||||||
let page2: Vec<String> = (1..10u64).map(tool_line).collect();
|
let page2: Vec<String> = (1..10u64).map(tool_line).collect();
|
||||||
assert!(session.store_page(&page2, 1, 10, true));
|
assert!(session.store_page(&page2, 1, 10, true));
|
||||||
assert_eq!(session.page(10, 5, false), None);
|
assert_eq!(session.page(10, 5, false), None);
|
||||||
@@ -1095,8 +905,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// At or below where the run starts, so what the reader is
|
|
||||||
// scrolling into is the server's.
|
|
||||||
assert_eq!(session.page(100, 40, true), None);
|
assert_eq!(session.page(100, 40, true), None);
|
||||||
assert_eq!(session.page(40, 40, true), None);
|
assert_eq!(session.page(40, 40, true), None);
|
||||||
assert_eq!(cache.session("never-visited").page(100, 40, true), None);
|
assert_eq!(cache.session("never-visited").page(100, 40, true), None);
|
||||||
@@ -1121,8 +929,6 @@ mod tests {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let cache = cache(temp.path());
|
let cache = cache(temp.path());
|
||||||
let session = cache.session("s");
|
let session = cache.session("s");
|
||||||
// Two replies of three deltas each, split by a tool call: the same
|
|
||||||
// fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`.
|
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
delta(1),
|
delta(1),
|
||||||
delta(2),
|
delta(2),
|
||||||
@@ -1137,14 +943,8 @@ mod tests {
|
|||||||
session.append(&tool_line(9), 9);
|
session.append(&tool_line(9), 9);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// Three rows: the tool call at 8, the run 5..7, and the tool call
|
|
||||||
// at 4. The cut lands between rows, so the older run is not
|
|
||||||
// started.
|
|
||||||
assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8]));
|
assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8]));
|
||||||
// One row is one whole run, however many deltas it is made of.
|
|
||||||
assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8]));
|
assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8]));
|
||||||
// A page of lines counts lines, which is what the anchor restore
|
|
||||||
// asks for.
|
|
||||||
assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8]));
|
assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1163,10 +963,7 @@ mod tests {
|
|||||||
session.append(&tool_line(10), 10);
|
session.append(&tool_line(10), 10);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// A run straddling the boundary is one row, as it will be once folded.
|
|
||||||
assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10]));
|
assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10]));
|
||||||
// Asking for more rows than the suffix holds is a short page, not a
|
|
||||||
// failure and not a claim that the conversation starts here.
|
|
||||||
assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect()));
|
assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1190,13 +987,9 @@ mod tests {
|
|||||||
session.append(&tool_line(90), 90);
|
session.append(&tool_line(90), 90);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// The run behind the gap, which is what makes the fetched page
|
|
||||||
// adjacent to it.
|
|
||||||
assert_eq!(session.covered_up_to(90), Some(40));
|
assert_eq!(session.covered_up_to(90), Some(40));
|
||||||
assert_eq!(session.covered_up_to(41), Some(40));
|
assert_eq!(session.covered_up_to(41), Some(40));
|
||||||
assert_eq!(session.covered_up_to(10), Some(10));
|
assert_eq!(session.covered_up_to(10), Some(10));
|
||||||
// Nothing at or below the oldest chunk's start, so the page is
|
|
||||||
// bounded only by its limit.
|
|
||||||
assert_eq!(session.covered_up_to(9), None);
|
assert_eq!(session.covered_up_to(9), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,9 +1005,6 @@ mod tests {
|
|||||||
&(1..10u64).map(tool_line).collect::<Vec<_>>(),
|
&(1..10u64).map(tool_line).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only reachable by dying between closing one live run and opening
|
|
||||||
// the next, and there is no cursor to be read off a coalesced line
|
|
||||||
// -- so the open is a cold one.
|
|
||||||
assert_eq!(session.tail(), None);
|
assert_eq!(session.tail(), None);
|
||||||
assert!(!dir_of(temp.path(), "s").exists());
|
assert!(!dir_of(temp.path(), "s").exists());
|
||||||
}
|
}
|
||||||
@@ -1243,7 +1033,6 @@ mod tests {
|
|||||||
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
|
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
|
||||||
format!("{}\n{}\n", tool_line(1), tool_line(2))
|
format!("{}\n{}\n", tool_line(1), tool_line(2))
|
||||||
);
|
);
|
||||||
// And the run continues from where the good tail left off.
|
|
||||||
session.append(&tool_line(3), 3);
|
session.append(&tool_line(3), 3);
|
||||||
session.flush();
|
session.flush();
|
||||||
assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]);
|
assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]);
|
||||||
@@ -1261,7 +1050,6 @@ mod tests {
|
|||||||
&[tool_line(1), "not ours".to_string(), tool_line(3)],
|
&[tool_line(1), "not ours".to_string(), tool_line(3)],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Not seen by the tail, which reads the newest line and stops.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session.tail(),
|
session.tail(),
|
||||||
Some(CachedTail {
|
Some(CachedTail {
|
||||||
@@ -1269,8 +1057,6 @@ mod tests {
|
|||||||
line: tool_line(3)
|
line: tool_line(3)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
// Reached by a read that walks past it: what is served is nothing,
|
|
||||||
// and the session opens cold from here on.
|
|
||||||
assert_eq!(session.newest(80), Vec::<String>::new());
|
assert_eq!(session.newest(80), Vec::<String>::new());
|
||||||
assert!(!dir_of(temp.path(), "s").exists());
|
assert!(!dir_of(temp.path(), "s").exists());
|
||||||
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
|
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
|
||||||
@@ -1298,9 +1084,6 @@ mod tests {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let cache = cache(temp.path());
|
let cache = cache(temp.path());
|
||||||
let session = cache.session("s");
|
let session = cache.session("s");
|
||||||
// Well past the 64 kB block the backwards reader takes at a time,
|
|
||||||
// so a page has to be stitched across several of them -- including
|
|
||||||
// a line that straddles a boundary.
|
|
||||||
let padding = "x".repeat(300);
|
let padding = "x".repeat(300);
|
||||||
let lines: Vec<String> = (1..=500u64)
|
let lines: Vec<String> = (1..=500u64)
|
||||||
.map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#))
|
.map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#))
|
||||||
@@ -1310,8 +1093,6 @@ mod tests {
|
|||||||
assert_eq!(session.tail().unwrap().seq, 500);
|
assert_eq!(session.tail().unwrap().seq, 500);
|
||||||
assert_eq!(session.newest(80), lines[420..].to_vec());
|
assert_eq!(session.newest(80), lines[420..].to_vec());
|
||||||
assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec()));
|
assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec()));
|
||||||
// And a non-ASCII line, whose bytes a naive split could cut through
|
|
||||||
// a character.
|
|
||||||
let accented =
|
let accented =
|
||||||
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
|
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
|
||||||
session.append(&accented, 501);
|
session.append(&accented, 501);
|
||||||
@@ -1333,15 +1114,10 @@ mod tests {
|
|||||||
let when =
|
let when =
|
||||||
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64);
|
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64);
|
||||||
filetime_set_modified(&dir_of(temp.path(), id), when).unwrap();
|
filetime_set_modified(&dir_of(temp.path(), id), when).unwrap();
|
||||||
// The mtime touch above always sets "now", not `when` (see its
|
|
||||||
// own doc) -- space the three writes out in real time instead,
|
|
||||||
// since only relative order matters to eviction.
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
let each = dir_size(&dir_of(temp.path(), "old"));
|
let each = dir_size(&dir_of(temp.path(), "old"));
|
||||||
|
|
||||||
// Room for two of the three, so the oldest goes -- and the session
|
|
||||||
// being read never does, however long ago it was last touched.
|
|
||||||
cache.evict_to_budget("open", each * 2);
|
cache.evict_to_budget("open", each * 2);
|
||||||
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
|
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1394,8 +1170,6 @@ mod tests {
|
|||||||
session.purge();
|
session.purge();
|
||||||
assert_eq!(session.bytes(), 0);
|
assert_eq!(session.bytes(), 0);
|
||||||
assert_eq!(session.tail(), None);
|
assert_eq!(session.tail(), None);
|
||||||
// And the session is usable again straight afterwards, which is
|
|
||||||
// what a reload does next.
|
|
||||||
session.append(&tool_line(9), 9);
|
session.append(&tool_line(9), 9);
|
||||||
session.flush();
|
session.flush();
|
||||||
assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
|
assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
|
||||||
@@ -1,32 +1,5 @@
|
|||||||
//! What the transcript renders: the event stream folded into displayable
|
|
||||||
//! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s
|
|
||||||
//! non-Compose half (`TranscriptRow`, `groupToolRuns`).
|
|
||||||
//!
|
|
||||||
//! Events are the only data source, and there is deliberately no second
|
|
||||||
//! shape for history to drift from: a page fetched backwards, a live
|
|
||||||
//! frame, and a line read out of the transcript cache are all the same
|
|
||||||
//! events through the same fold.
|
|
||||||
//!
|
|
||||||
//! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into
|
|
||||||
//! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer
|
|
||||||
//! exists to bound how much a lazy list composes per frame, which is a
|
|
||||||
//! fact about the UI framework drawing it, not about the transcript. See
|
|
||||||
//! `CLIENT_CORE.md`.
|
|
||||||
//!
|
|
||||||
//! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate
|
|
||||||
//! deserializes straight into [`event_model::Event`], which has no
|
|
||||||
//! `Unknown` catch-all -- an event type this build does not recognise
|
|
||||||
//! fails to parse rather than degrading to a placeholder row. Closing that
|
|
||||||
//! gap means giving `event_model::Event` its own forward-compatible
|
|
||||||
//! variant, which is a shared-model decision for both sides of the wire
|
|
||||||
//! and is deliberately left for whoever picks this up next (see
|
|
||||||
//! `CLIENT_CORE.md`).
|
|
||||||
|
|
||||||
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
|
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
|
||||||
|
|
||||||
/// A question this build has already asked the reader about, with what was
|
|
||||||
/// answered so far -- distinct from [`QuestionOption`], which is what could
|
|
||||||
/// be chosen.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct QuestionCard {
|
pub struct QuestionCard {
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
@@ -38,24 +11,14 @@ pub struct QuestionCard {
|
|||||||
pub answers: Vec<String>,
|
pub answers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tool call cannot be recognised as `AskUserQuestion` from a bare
|
|
||||||
/// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption
|
|
||||||
/// logic name it explicitly.
|
|
||||||
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
|
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
|
||||||
|
|
||||||
/// This item's identity in the list: a `Seq` for everything with no
|
|
||||||
/// identity of its own, `RunId` for a tool call (which keeps one across
|
|
||||||
/// however many calls join or leave its run), matching `TranscriptItem.key`
|
|
||||||
/// in the Kotlin original.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub enum ItemKey {
|
pub enum ItemKey {
|
||||||
Seq(u64),
|
Seq(u64),
|
||||||
RunId(String),
|
RunId(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the transcript, folded from [`Event`]s. See each variant's
|
|
||||||
/// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this
|
|
||||||
/// doc only says what changed in translation.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum TranscriptItem {
|
pub enum TranscriptItem {
|
||||||
UserMsg {
|
UserMsg {
|
||||||
@@ -66,8 +29,6 @@ pub enum TranscriptItem {
|
|||||||
AssistantMsg {
|
AssistantMsg {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
text: String,
|
text: String,
|
||||||
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
|
|
||||||
/// Kotlin doc for why the split it licenses matters.
|
|
||||||
settled: bool,
|
settled: bool,
|
||||||
},
|
},
|
||||||
ToolRun {
|
ToolRun {
|
||||||
@@ -78,6 +39,11 @@ pub enum TranscriptItem {
|
|||||||
input: String,
|
input: String,
|
||||||
output: String,
|
output: String,
|
||||||
done: bool,
|
done: bool,
|
||||||
|
/// Whether the result that arrived said the call failed
|
||||||
|
/// ([`Event::ToolEnd`]'s `is_error`). Meaningless while `done` is
|
||||||
|
/// false, and [`ToolState::of`] is the only thing that reads the
|
||||||
|
/// pair, so the two cannot be combined wrongly at a call site.
|
||||||
|
failed: bool,
|
||||||
asks: Vec<QuestionCard>,
|
asks: Vec<QuestionCard>,
|
||||||
images: Vec<String>,
|
images: Vec<String>,
|
||||||
},
|
},
|
||||||
@@ -90,9 +56,6 @@ pub enum TranscriptItem {
|
|||||||
seq: u64,
|
seq: u64,
|
||||||
r#ref: String,
|
r#ref: String,
|
||||||
},
|
},
|
||||||
/// A message from another agent. `arrived` is this row's own identity
|
|
||||||
/// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which
|
|
||||||
/// [`place_peer_note`] may set to the turn's opening seq instead.
|
|
||||||
PeerNote {
|
PeerNote {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
from: String,
|
from: String,
|
||||||
@@ -103,8 +66,6 @@ pub enum TranscriptItem {
|
|||||||
seq: u64,
|
seq: u64,
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
/// Placeholder for an event kind this build could not fold -- see the
|
|
||||||
/// module doc's "known gap".
|
|
||||||
Note {
|
Note {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
text: String,
|
text: String,
|
||||||
@@ -196,14 +157,10 @@ fn update_tool(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a status means the session is still doing something, mirroring
|
|
||||||
/// `sessionWorking` in `Events.kt`.
|
|
||||||
pub fn session_working(status: SessionStatus) -> bool {
|
pub fn session_working(status: SessionStatus) -> bool {
|
||||||
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
|
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A status saying the session stopped working is the moment its newest
|
|
||||||
/// reply is finished.
|
|
||||||
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
|
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
|
||||||
if session_working(status) {
|
if session_working(status) {
|
||||||
return items.to_vec();
|
return items.to_vec();
|
||||||
@@ -218,9 +175,6 @@ fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<Transcri
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A peer message goes above the turn it started, not where it happened to
|
|
||||||
/// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning;
|
|
||||||
/// `turn_start` is `Event::PeerMessage`'s own field of that name.
|
|
||||||
fn place_peer_note(
|
fn place_peer_note(
|
||||||
items: &[TranscriptItem],
|
items: &[TranscriptItem],
|
||||||
seq: u64,
|
seq: u64,
|
||||||
@@ -259,8 +213,6 @@ fn place_peer_note(
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The calls the note now sits in front of, renamed if they were sharing a
|
|
||||||
/// run with the calls behind it. See the Kotlin `splitRun`'s doc.
|
|
||||||
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
|
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
|
||||||
let Some(TranscriptItem::ToolRun {
|
let Some(TranscriptItem::ToolRun {
|
||||||
run_id: first_run_id,
|
run_id: first_run_id,
|
||||||
@@ -294,14 +246,6 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Puts a page of older items in front of the ones already loaded, healing
|
|
||||||
/// whatever the page boundary cut in two. Ported from `TranscriptItems.kt`'s
|
|
||||||
/// `joinPages`.
|
|
||||||
///
|
|
||||||
/// Two things straddle a boundary: a tool call separated from its result,
|
|
||||||
/// and a message separated from the rest of itself. Both were one thing
|
|
||||||
/// before the transcript was cut into pages.
|
|
||||||
///
|
|
||||||
/// A boundary lands wherever it lands, and roughly half the time that is
|
/// A boundary lands wherever it lands, and roughly half the time that is
|
||||||
/// between a call and its result. The newer page then holds a `ToolEnd`
|
/// between a call and its result. The newer page then holds a `ToolEnd`
|
||||||
/// whose start it never saw, which `fold_event` draws as a row of its own
|
/// whose start it never saw, which `fold_event` draws as a row of its own
|
||||||
@@ -314,23 +258,12 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
|||||||
/// exactly what a page boundary destroys. The older row wins on what a
|
/// exactly what a page boundary destroys. The older row wins on what a
|
||||||
/// start knows and the newer on what an end knows, which is the only way
|
/// start knows and the newer on what an end knows, which is the only way
|
||||||
/// round that loses nothing.
|
/// round that loses nothing.
|
||||||
///
|
|
||||||
/// The third thing is the *run*, and it is the one the Kotlin original used
|
|
||||||
/// to miss (AGENTS.md's "things that have bitten"): every page ends up
|
|
||||||
/// here, but `adopt_run` must run on *every* join, not only the one where a
|
|
||||||
/// split call was found -- a boundary landing cleanly between two finished
|
|
||||||
/// calls, which is most of them, would otherwise leave the older page's
|
|
||||||
/// calls under the run name they were folded with. On screen: one run of
|
|
||||||
/// tool calls drawn as two groups, with the seam wherever the reader
|
|
||||||
/// happened to have paged.
|
|
||||||
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||||
let (older, newer) = heal_split_message(earlier, later);
|
let (older, newer) = heal_split_message(earlier, later);
|
||||||
let started_earlier: std::collections::HashSet<&str> = older
|
let started_earlier: std::collections::HashSet<&str> = older
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(TranscriptItem::as_tool_run)
|
.filter_map(TranscriptItem::as_tool_run)
|
||||||
.collect();
|
.collect();
|
||||||
// Owned rather than borrowed from `newer`: `kept` below needs to consume `newer` by
|
|
||||||
// value, and a map borrowing it would keep that alive.
|
|
||||||
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
|
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
|
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
|
||||||
@@ -352,6 +285,7 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
|||||||
let &TranscriptItem::ToolRun {
|
let &TranscriptItem::ToolRun {
|
||||||
ref output,
|
ref output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
asks: ref half_asks,
|
asks: ref half_asks,
|
||||||
images: ref half_images,
|
images: ref half_images,
|
||||||
..
|
..
|
||||||
@@ -367,9 +301,7 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
|||||||
input,
|
input,
|
||||||
output: output.clone(),
|
output: output.clone(),
|
||||||
done,
|
done,
|
||||||
// Kept from both halves: a question or an image can be
|
failed,
|
||||||
// attached to either, depending on which side of the
|
|
||||||
// boundary its event fell.
|
|
||||||
asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
|
asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
|
||||||
images: row_images.into_iter().chain(half_images.clone()).collect(),
|
images: row_images.into_iter().chain(half_images.clone()).collect(),
|
||||||
}
|
}
|
||||||
@@ -404,18 +336,10 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rejoins a message the page boundary cut, and hands back the two pages to
|
|
||||||
/// concatenate. Ported from `TranscriptItems.kt`'s `healSplitMessage`.
|
|
||||||
///
|
|
||||||
/// `fold_event` never leaves two assistant messages next to each other
|
/// `fold_event` never leaves two assistant messages next to each other
|
||||||
/// inside one page, so two meeting at a join are always the two halves of
|
/// inside one page, so two meeting at a join are always the two halves of
|
||||||
/// one reply, and leaving them apart drew a single answer as two with a
|
/// one reply, and leaving them apart drew a single answer as two with a
|
||||||
/// paragraph break through the middle of a sentence.
|
/// paragraph break through the middle of a sentence.
|
||||||
///
|
|
||||||
/// The newer half keeps its identity, for the reason `adopt_run`'s doc
|
|
||||||
/// gives. It grows by what the older half brings, which is safe here and
|
|
||||||
/// nowhere else -- the join is at the oldest end of what is loaded, so the
|
|
||||||
/// growth extends off the top of the screen.
|
|
||||||
fn heal_split_message(
|
fn heal_split_message(
|
||||||
earlier: &[TranscriptItem],
|
earlier: &[TranscriptItem],
|
||||||
later: &[TranscriptItem],
|
later: &[TranscriptItem],
|
||||||
@@ -443,21 +367,10 @@ fn heal_split_message(
|
|||||||
(earlier[..earlier.len() - 1].to_vec(), newer)
|
(earlier[..earlier.len() - 1].to_vec(), newer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hands the older calls at the join the name of the run they are joining.
|
|
||||||
/// Ported from `TranscriptItems.kt`'s `adoptRun`.
|
|
||||||
///
|
|
||||||
/// The two pages were folded separately, so a run split by the boundary
|
|
||||||
/// came back as two runs with two names. Naming the joined run after the
|
|
||||||
/// *older* half would be the obvious way round and is wrong: the newer half
|
|
||||||
/// is the part already on screen, and renaming it is renaming the row the
|
|
||||||
/// reader is looking at, which is how a list loses its anchor.
|
|
||||||
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||||
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
|
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
|
||||||
return earlier.to_vec();
|
return earlier.to_vec();
|
||||||
};
|
};
|
||||||
// A question is in a run of its own on both sides of the join, the same as it would be
|
|
||||||
// had the two pages been folded as one. Without this the heal would merge a group
|
|
||||||
// straight through the row the reader was asked something on.
|
|
||||||
if tool == ASK_USER_QUESTION {
|
if tool == ASK_USER_QUESTION {
|
||||||
return earlier.to_vec();
|
return earlier.to_vec();
|
||||||
}
|
}
|
||||||
@@ -487,10 +400,6 @@ fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<Transc
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
|
||||||
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
|
||||||
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
|
||||||
/// at the parse layer).
|
|
||||||
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
|
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
|
||||||
let seq = entry.seq;
|
let seq = entry.seq;
|
||||||
match &entry.event {
|
match &entry.event {
|
||||||
@@ -505,9 +414,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
});
|
});
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
// `MessageTaken` is folded into `UserMessage` by the manager before
|
|
||||||
// it reaches a phone (see `PLAN.md`); if one arrives here anyway
|
|
||||||
// (a raw transcript line, say), it reads the same way.
|
|
||||||
Event::MessageTaken {
|
Event::MessageTaken {
|
||||||
text, attachments, ..
|
text, attachments, ..
|
||||||
} => {
|
} => {
|
||||||
@@ -520,12 +426,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
Event::AssistantText { delta } => {
|
Event::AssistantText { delta } => {
|
||||||
// Deltas accumulate into the message they're streaming, which
|
|
||||||
// keeps the seq of the *first* of them: a row whose identity
|
|
||||||
// changed with every delta would be a new row every frame.
|
|
||||||
// "A message growing again is not finished" -- whatever a
|
|
||||||
// status said in between -- is why this always clears
|
|
||||||
// `settled` rather than preserving it.
|
|
||||||
if let Some(TranscriptItem::AssistantMsg {
|
if let Some(TranscriptItem::AssistantMsg {
|
||||||
seq: first_seq,
|
seq: first_seq,
|
||||||
text,
|
text,
|
||||||
@@ -562,6 +462,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: input.to_string(),
|
input: input.to_string(),
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
done: false,
|
done: false,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -572,15 +473,23 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Event::ToolEnd { id, output } => {
|
Event::ToolEnd {
|
||||||
|
id,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
} => {
|
||||||
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
||||||
update_tool(items, id, |item| {
|
update_tool(items, id, |item| {
|
||||||
if let TranscriptItem::ToolRun {
|
if let TranscriptItem::ToolRun {
|
||||||
output: out, done, ..
|
output: out,
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
..
|
||||||
} = item
|
} = item
|
||||||
{
|
{
|
||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
*done = true;
|
*done = true;
|
||||||
|
*failed = *is_error;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -594,6 +503,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: output.clone(),
|
output: output.clone(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: *is_error,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -650,6 +560,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
images,
|
images,
|
||||||
} if asks.iter().any(|a| &a.id == id) => {
|
} if asks.iter().any(|a| &a.id == id) => {
|
||||||
for ask in asks.iter_mut() {
|
for ask in asks.iter_mut() {
|
||||||
@@ -665,6 +576,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
asks,
|
asks,
|
||||||
images,
|
images,
|
||||||
}
|
}
|
||||||
@@ -685,7 +597,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
});
|
});
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
// Screen-level state, not transcript rows.
|
|
||||||
Event::CommandQueued { .. }
|
Event::CommandQueued { .. }
|
||||||
| Event::MessageQueued { .. }
|
| Event::MessageQueued { .. }
|
||||||
| Event::MessageDropped { .. }
|
| Event::MessageDropped { .. }
|
||||||
@@ -750,14 +661,55 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
/// The pair this enum exists for is [`ToolState::Succeeded`] against
|
||||||
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
/// [`ToolState::NoResult`]. A call that finished having printed nothing
|
||||||
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
/// and a call whose result never arrived both leave an empty `output`,
|
||||||
/// of this crate.
|
/// and drawing them the same way states a verdict nobody reached: "it
|
||||||
|
/// worked and said nothing" reads as a fact, where the truth is that the
|
||||||
|
/// turn ended before anything came back.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ToolState {
|
||||||
|
Running,
|
||||||
|
/// Stopped on the reader: a permission or question this call carries
|
||||||
|
/// has not been answered, so nothing is happening until somebody
|
||||||
|
/// answers it. Distinct from [`Self::Running`] because whose move it
|
||||||
|
/// is differs, which is the Compose card's "your turn".
|
||||||
|
Deciding,
|
||||||
|
Succeeded,
|
||||||
|
Failed,
|
||||||
|
NoResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolState {
|
||||||
|
pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> {
|
||||||
|
let TranscriptItem::ToolRun {
|
||||||
|
done, failed, asks, ..
|
||||||
|
} = item
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
debug_assert!(
|
||||||
|
!failed || *done,
|
||||||
|
"a call cannot have failed before its result arrived"
|
||||||
|
);
|
||||||
|
Some(if asks.iter().any(|ask| ask.answers.is_empty()) {
|
||||||
|
Self::Deciding
|
||||||
|
} else if !*done {
|
||||||
|
match session_working {
|
||||||
|
true => Self::Running,
|
||||||
|
false => Self::NoResult,
|
||||||
|
}
|
||||||
|
} else if *failed {
|
||||||
|
Self::Failed
|
||||||
|
} else {
|
||||||
|
Self::Succeeded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum TranscriptRow {
|
pub enum TranscriptRow {
|
||||||
Single(TranscriptItem),
|
Single(TranscriptItem),
|
||||||
/// Two or more calls with nothing between them.
|
|
||||||
Tools(Vec<TranscriptItem>),
|
Tools(Vec<TranscriptItem>),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,9 +729,6 @@ impl TranscriptRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs of adjacent tool calls become one row; everything else passes
|
|
||||||
/// through. See the Kotlin `groupRuns`'s doc for why grouping is by the
|
|
||||||
/// run each call names rather than by adjacency worked out here.
|
|
||||||
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
let mut run: Vec<TranscriptItem> = Vec::new();
|
let mut run: Vec<TranscriptItem> = Vec::new();
|
||||||
@@ -815,15 +764,6 @@ pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
|||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s
|
|
||||||
/// `Vec<Value>`) into the flat item list this module works over. A line
|
|
||||||
/// this build can't parse fails the whole page rather than being skipped --
|
|
||||||
/// CODE_RULES's "an enumeration must be able to say 'it broke'" -- since
|
|
||||||
/// silently dropping one event could hide, say, a user message that then
|
|
||||||
/// looks like it was never sent. Moved here from `desktop-app`'s `app.rs`
|
|
||||||
/// (RUST.md's E4) when the Android transcript client (I5) needed the same
|
|
||||||
/// fold: "write the logic once" applies to any caller embedding
|
|
||||||
/// `transcript-ui` against a live server, not just the first one.
|
|
||||||
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
|
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
for value in values {
|
for value in values {
|
||||||
@@ -835,13 +775,6 @@ pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, St
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wire `seq` a raw transcript line carries -- the live-stream resume
|
|
||||||
/// cursor after loading a page must be this, not a folded item's `seq()`.
|
|
||||||
/// A folded `AssistantMsg` keeps the seq of the *first* delta it
|
|
||||||
/// accumulated (`fold_event`'s own doc), so resuming from that seq would
|
|
||||||
/// re-deliver every delta already folded into it, duplicating the tail of
|
|
||||||
/// a reply that was mid-stream when the page was fetched -- found via a
|
|
||||||
/// real screenshot in E4 (RUST.md), where the assistant's line doubled.
|
|
||||||
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
|
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
|
||||||
value.get("seq")?.as_u64()
|
value.get("seq")?.as_u64()
|
||||||
}
|
}
|
||||||
@@ -989,6 +922,7 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "x".to_string(),
|
id: "x".to_string(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1001,6 +935,7 @@ mod tests {
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}]
|
}]
|
||||||
@@ -1072,12 +1007,6 @@ mod tests {
|
|||||||
obj
|
obj
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The regression for a bug a real `run-headless.sh` screenshot found
|
|
||||||
/// in `desktop-app` (E4, RUST.md): resuming the live stream from the
|
|
||||||
/// last *item's* seq re-delivers the deltas already folded into a
|
|
||||||
/// still-open assistant message, doubling its tail. `raw_seq` of the
|
|
||||||
/// last wire line must be the true high-water mark instead, which for a
|
|
||||||
/// run of deltas is higher than every item's own `seq()`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
|
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
|
||||||
let values = vec![
|
let values = vec![
|
||||||
@@ -1166,17 +1095,11 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
output: output.to_string(),
|
output: output.to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AGENTS.md's "things that have bitten": `joinPages` used to run
|
|
||||||
/// `adoptRun` only on the path where a *split* call was found, so a
|
|
||||||
/// boundary landing cleanly between two already-finished calls -- most
|
|
||||||
/// of them -- left the older page's calls under the run name they were
|
|
||||||
/// folded with, drawing one run of tool calls as two groups. Two
|
|
||||||
/// finished, unrelated calls (no id in common) must still end up under
|
|
||||||
/// one run name after the join.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
|
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
|
||||||
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
|
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
|
||||||
@@ -1211,6 +1134,7 @@ mod tests {
|
|||||||
input: "{}".to_string(),
|
input: "{}".to_string(),
|
||||||
output: "the result".to_string(),
|
output: "the result".to_string(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}],
|
}],
|
||||||
@@ -1255,9 +1179,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A question is in a run of its own on both sides of a join -- healing
|
|
||||||
/// must never rename the run of calls the reader was asked something
|
|
||||||
/// on, the same rule `splitRun` enforces for a live turn boundary.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adopt_run_never_renames_into_a_question_row() {
|
fn adopt_run_never_renames_into_a_question_row() {
|
||||||
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
|
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
|
||||||
@@ -1269,6 +1190,7 @@ mod tests {
|
|||||||
input: "{}".to_string(),
|
input: "{}".to_string(),
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
done: false,
|
done: false,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}];
|
}];
|
||||||
@@ -1279,3 +1201,137 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tool_state_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn event(seq: u64, e: Event) -> SeqEvent {
|
||||||
|
SeqEvent {
|
||||||
|
seq,
|
||||||
|
ts: 0.0,
|
||||||
|
event: e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_all(events: &[SeqEvent]) -> Vec<TranscriptItem> {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.fold(Vec::new(), |items, e| fold_event(&items, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(id: &str) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: id.to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({"command": "ls"}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end(id: &str, output: &str, is_error: bool) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: id.to_string(),
|
||||||
|
output: output.to_string(),
|
||||||
|
is_error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_of(events: &[SeqEvent], session_working: bool) -> ToolState {
|
||||||
|
let items = fold_all(events);
|
||||||
|
ToolState::of(&items[0], session_working).expect("the fixture's first item is a tool call")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_result_that_arrived_is_read_from_is_error() {
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "ok", false)], false),
|
||||||
|
ToolState::Succeeded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "No such file", true)], false),
|
||||||
|
ToolState::Failed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() {
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "", false)], false),
|
||||||
|
ToolState::Succeeded,
|
||||||
|
"a result arrived; it was empty"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a")], false),
|
||||||
|
ToolState::NoResult,
|
||||||
|
"no result, and the session is not working any more"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_result_while_the_session_works_is_still_running() {
|
||||||
|
assert_eq!(state_of(&[start("a")], true), ToolState::Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unanswered_ask_is_the_readers_move_whatever_else_is_true() {
|
||||||
|
let asking = event(
|
||||||
|
3,
|
||||||
|
Event::Question {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
prompt: "Allow?".to_string(),
|
||||||
|
header: None,
|
||||||
|
options: vec![QuestionOption {
|
||||||
|
label: "Allow".to_string(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
}],
|
||||||
|
multi_select: false,
|
||||||
|
about: Some("a".to_string()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let answered = event(
|
||||||
|
4,
|
||||||
|
Event::Answered {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
answers: vec!["Allow".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), asking.clone()], true),
|
||||||
|
ToolState::Deciding
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), asking.clone()], false),
|
||||||
|
ToolState::Deciding
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(
|
||||||
|
&[start("a"), asking, answered, end("a", "ok", false)],
|
||||||
|
false
|
||||||
|
),
|
||||||
|
ToolState::Succeeded,
|
||||||
|
"once it is answered the call is an ordinary one again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_but_a_tool_call_has_a_tool_state() {
|
||||||
|
assert_eq!(
|
||||||
|
ToolState::of(
|
||||||
|
&TranscriptItem::UserMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
true
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,9 @@
|
|||||||
//! Where a session screen gets a transcript from: this phone's copy first,
|
|
||||||
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
|
|
||||||
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
|
|
||||||
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
|
|
||||||
//!
|
|
||||||
//! One seam rather than a cache the screen has to remember to consult.
|
|
||||||
//! Everything fetched before is asked of this, and everything the server
|
|
||||||
//! sends is written into the cache on the way past, so a caller never
|
|
||||||
//! learns which side answered. The one rule worth keeping in mind: the
|
|
||||||
//! cache is never load-bearing. Every read here has a network path beside
|
|
||||||
//! it producing the same result.
|
|
||||||
//!
|
|
||||||
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
|
|
||||||
//! ability to close a live stream from another thread. Both are wall-clock
|
|
||||||
//! and thread-lifetime concerns that belong to whatever runtime the caller
|
|
||||||
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
|
|
||||||
//! scope) rather than to this pure logic -- `follow` below is the same
|
|
||||||
//! decorator shape `iris/desktop-app/src/app.rs` and
|
|
||||||
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
|
|
||||||
//! `event_stream::follow_session_events`, just with the cache write built
|
|
||||||
//! in so a future caller does not have to repeat it a third time.
|
|
||||||
|
|
||||||
use event_model::SeqEvent;
|
use event_model::SeqEvent;
|
||||||
|
|
||||||
use crate::api::{ApiClient, ApiError, Transport};
|
use crate::client::api::{ApiClient, ApiError, Transport};
|
||||||
use crate::event_stream::{self, StreamItem};
|
use crate::client::event_stream::{self, StreamItem};
|
||||||
use crate::transcript_cache::SessionCache;
|
use crate::client::transcript_cache::SessionCache;
|
||||||
|
|
||||||
/// How many events a session screen opens with, cached or fetched.
|
|
||||||
///
|
|
||||||
/// The server's own default page size, named here because the cached
|
/// 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
|
/// 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`
|
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
|
||||||
@@ -49,9 +25,6 @@ impl std::fmt::Display for ParseError {
|
|||||||
}
|
}
|
||||||
impl std::error::Error for ParseError {}
|
impl std::error::Error for ParseError {}
|
||||||
|
|
||||||
/// Either half of what can go wrong asking for a page: the network, or a
|
|
||||||
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
|
|
||||||
/// read.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum PageError {
|
pub enum PageError {
|
||||||
Api(ApiError),
|
Api(ApiError),
|
||||||
@@ -70,23 +43,9 @@ impl From<ParseError> for PageError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`TranscriptSource::page`] found, kept as two states rather than
|
|
||||||
/// one possibly-empty list.
|
|
||||||
///
|
|
||||||
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
|
|
||||||
/// empty [`Self::Events`] means "this conversation has no more history",
|
|
||||||
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
|
|
||||||
/// question could not be asked yet, which it must not. Collapsing the two
|
|
||||||
/// into an empty `Vec` puts the bug back, because the caller cannot tell
|
|
||||||
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
|
|
||||||
/// same silently.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum OlderPage {
|
pub enum OlderPage {
|
||||||
/// The events before the cursor, oldest first. Empty means the start
|
|
||||||
/// of the conversation has been reached.
|
|
||||||
Events(Vec<SeqEvent>),
|
Events(Vec<SeqEvent>),
|
||||||
/// Nothing is loaded, so there was no cursor to page back from
|
|
||||||
/// (`before == 0`). Not an answer about the conversation at all.
|
|
||||||
NothingLoaded,
|
NothingLoaded,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +53,6 @@ fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
|||||||
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This phone's copy of one session's transcript, plus the server it
|
|
||||||
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
|
|
||||||
pub struct TranscriptSource<T: Transport> {
|
pub struct TranscriptSource<T: Transport> {
|
||||||
api: ApiClient<T>,
|
api: ApiClient<T>,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@@ -113,11 +70,6 @@ impl<T: Transport> TranscriptSource<T> {
|
|||||||
|
|
||||||
/// The cached opening window, or `None` when there is nothing usable
|
/// The cached opening window, or `None` when there is nothing usable
|
||||||
/// to draw.
|
/// to draw.
|
||||||
///
|
|
||||||
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
|
|
||||||
/// whole point of the feature: the rows are on screen while the check
|
|
||||||
/// that they are still the server's rows is in flight, and a failed
|
|
||||||
/// check replaces them exactly as a reset does.
|
|
||||||
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
|
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
|
||||||
self.cache.tail()?;
|
self.cache.tail()?;
|
||||||
let lines = self.cache.newest(limit);
|
let lines = self.cache.newest(limit);
|
||||||
@@ -126,9 +78,6 @@ impl<T: Transport> TranscriptSource<T> {
|
|||||||
}
|
}
|
||||||
match lines.iter().map(|l| parse_line(l)).collect() {
|
match lines.iter().map(|l| parse_line(l)).collect() {
|
||||||
Ok(events) => Some(events),
|
Ok(events) => Some(events),
|
||||||
// A line this build cannot read at all, which the cache's own checks cannot
|
|
||||||
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
|
|
||||||
// open.
|
|
||||||
Err(ParseError(_)) => {
|
Err(ParseError(_)) => {
|
||||||
self.cache.purge();
|
self.cache.purge();
|
||||||
None
|
None
|
||||||
@@ -136,9 +85,6 @@ impl<T: Transport> TranscriptSource<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the server's event at the cached cursor is still the cached
|
|
||||||
/// one.
|
|
||||||
///
|
|
||||||
/// A caller must not resume a live stream from a cached seq unless it
|
/// 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
|
/// is the same conversation: a transcript is append-only in ordinary
|
||||||
/// use, but the file backing it can be replaced or truncated (a
|
/// use, but the file backing it can be replaced or truncated (a
|
||||||
@@ -151,15 +97,10 @@ impl<T: Transport> TranscriptSource<T> {
|
|||||||
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
|
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
|
||||||
/// server not being askable, which is neither: the cached rows stay
|
/// server not being askable, which is neither: the cached rows stay
|
||||||
/// on screen and the caller tries again on its own reconnect schedule.
|
/// on screen and the caller tries again on its own reconnect schedule.
|
||||||
///
|
|
||||||
/// What this cannot see is a line changed in the middle of the file
|
|
||||||
/// with the tail intact -- that is what a full reload is for.
|
|
||||||
pub fn probe(&self) -> Result<bool, ApiError> {
|
pub fn probe(&self) -> Result<bool, ApiError> {
|
||||||
let Some(tail) = self.cache.tail() else {
|
let Some(tail) = self.cache.tail() else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
|
|
||||||
// event *at* the cursor when the server still has one there.
|
|
||||||
let page = self.api.fetch_transcript_lines(
|
let page = self.api.fetch_transcript_lines(
|
||||||
&self.session_id,
|
&self.session_id,
|
||||||
Some(tail.seq + 1),
|
Some(tail.seq + 1),
|
||||||
@@ -177,9 +118,6 @@ impl<T: Transport> TranscriptSource<T> {
|
|||||||
Ok(matches)
|
Ok(matches)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Today's opening fetch, kept as the start of the live run. Only
|
|
||||||
/// called when the cache has nothing to open with, or when
|
|
||||||
/// [`Self::probe`] said what it had was not the server's.
|
|
||||||
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
|
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
|
||||||
let page =
|
let page =
|
||||||
self.api
|
self.api
|
||||||
@@ -193,21 +131,6 @@ impl<T: Transport> TranscriptSource<T> {
|
|||||||
|
|
||||||
/// The page before `before`: from the cache when it holds it,
|
/// The page before `before`: from the cache when it holds it,
|
||||||
/// otherwise from the server bounded by what the cache already has.
|
/// otherwise from the server bounded by what the cache already has.
|
||||||
///
|
|
||||||
/// The server bound (`after`) is what keeps the cache worth having. A
|
|
||||||
/// coalesced page reaches back as far as its row count takes it -- a
|
|
||||||
/// single reply is hundreds of lines -- so a page fetched after the
|
|
||||||
/// reader has been away could run straight past the cached run and
|
|
||||||
/// overlap it, and an overlapping page cannot be stored. Told where
|
|
||||||
/// this phone's copy starts, the server stops there instead.
|
|
||||||
///
|
|
||||||
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
|
|
||||||
/// the cache or the server anything -- see AGENTS.md's "things that
|
|
||||||
/// have bitten": there is no event before the first one, so the
|
|
||||||
/// request is not a harmless no-op, and its empty answer is
|
|
||||||
/// indistinguishable from having reached the start of history.
|
|
||||||
/// Guarded here rather than left to every caller, because it is a fact
|
|
||||||
/// about the question, not about who is asking it.
|
|
||||||
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
|
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
|
||||||
if before == 0 {
|
if before == 0 {
|
||||||
return Ok(OlderPage::NothingLoaded);
|
return Ok(OlderPage::NothingLoaded);
|
||||||
@@ -228,9 +151,6 @@ impl<T: Transport> TranscriptSource<T> {
|
|||||||
after,
|
after,
|
||||||
)?;
|
)?;
|
||||||
if let Some((_, first_event)) = page.first() {
|
if let Some((_, first_event)) = page.first() {
|
||||||
// `before` rather than the newest line's seq: a coalesced page covers
|
|
||||||
// everything up to the cursor it was asked with, and nothing in its lines
|
|
||||||
// says so.
|
|
||||||
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
|
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
|
||||||
self.cache
|
self.cache
|
||||||
.store_page(&lines, first_event.seq, before, coalesce);
|
.store_page(&lines, first_event.seq, before, coalesce);
|
||||||
@@ -240,9 +160,6 @@ impl<T: Transport> TranscriptSource<T> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`event_stream::follow_session_events`], with every frame written to
|
|
||||||
/// the cache before `on_item` sees it.
|
|
||||||
///
|
|
||||||
/// Before, so that an event held back for a reader who is scrolled
|
/// 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
|
/// 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
|
/// sent, not what a screen has got round to drawing. Flushed on each
|
||||||
@@ -284,16 +201,11 @@ impl<T: Transport> TranscriptSource<T> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::{Body, RawResponse};
|
use crate::client::api::{Body, RawResponse};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// A transport that answers fixed bodies in call order, and records
|
|
||||||
/// every path it was asked for -- so a test can assert *how many*
|
|
||||||
/// requests a method made, which is the point for the `before == 0`
|
|
||||||
/// guard (AGENTS.md's regression: the guard must stop the request
|
|
||||||
/// before it happens, not merely tolerate the empty answer).
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct ScriptedTransport {
|
struct ScriptedTransport {
|
||||||
responses: Mutex<VecDeque<(u16, String)>>,
|
responses: Mutex<VecDeque<(u16, String)>>,
|
||||||
@@ -352,7 +264,7 @@ mod tests {
|
|||||||
cache_root: &std::path::Path,
|
cache_root: &std::path::Path,
|
||||||
) -> TranscriptSource<ScriptedTransport> {
|
) -> TranscriptSource<ScriptedTransport> {
|
||||||
let api = ApiClient::new(transport);
|
let api = ApiClient::new(transport);
|
||||||
let cache = crate::transcript_cache::TranscriptCache::new(cache_root).session("s1");
|
let cache = crate::client::transcript_cache::TranscriptCache::new(cache_root).session("s1");
|
||||||
TranscriptSource::new(api, "s1", cache)
|
TranscriptSource::new(api, "s1", cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +283,6 @@ mod tests {
|
|||||||
let opening = source.fetch_opening().unwrap();
|
let opening = source.fetch_opening().unwrap();
|
||||||
assert_eq!(opening.len(), 1);
|
assert_eq!(opening.len(), 1);
|
||||||
assert_eq!(opening[0].seq, 1);
|
assert_eq!(opening[0].seq, 1);
|
||||||
// The fetch wrote through: reopening the same cache now has something to show.
|
|
||||||
assert!(source.cache.tail().is_some());
|
assert!(source.cache.tail().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +296,7 @@ mod tests {
|
|||||||
|
|
||||||
let transport2 = ScriptedTransport::default();
|
let transport2 = ScriptedTransport::default();
|
||||||
transport2.respond(200, format!("[{}]", status_line(1)));
|
transport2.respond(200, format!("[{}]", status_line(1)));
|
||||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
assert!(source2.probe().unwrap());
|
assert!(source2.probe().unwrap());
|
||||||
assert!(source2.cache.tail().is_some());
|
assert!(source2.cache.tail().is_some());
|
||||||
@@ -399,12 +310,10 @@ mod tests {
|
|||||||
let source = source(transport, dir.path());
|
let source = source(transport, dir.path());
|
||||||
source.fetch_opening().unwrap();
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
// The server now answers with a different event at the same seq -- the file
|
|
||||||
// behind this session was replaced.
|
|
||||||
let transport2 = ScriptedTransport::default();
|
let transport2 = ScriptedTransport::default();
|
||||||
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
|
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
|
||||||
transport2.respond(200, format!("[{different}]"));
|
transport2.respond(200, format!("[{different}]"));
|
||||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
assert!(!source2.probe().unwrap());
|
assert!(!source2.probe().unwrap());
|
||||||
assert!(source2.cache.tail().is_none());
|
assert!(source2.cache.tail().is_none());
|
||||||
@@ -420,7 +329,7 @@ mod tests {
|
|||||||
|
|
||||||
let transport2 = ScriptedTransport::default();
|
let transport2 = ScriptedTransport::default();
|
||||||
transport2.respond(500, "server on fire");
|
transport2.respond(500, "server on fire");
|
||||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
assert!(source2.probe().is_err());
|
assert!(source2.probe().is_err());
|
||||||
assert!(
|
assert!(
|
||||||
@@ -429,10 +338,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The regression this module exists to close: `before == 0` must
|
|
||||||
/// never reach the network or the cache, because an empty answer there
|
|
||||||
/// is indistinguishable from "there is genuinely no more history" --
|
|
||||||
/// AGENTS.md's `loadOlderPage` incident.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn paging_before_the_first_event_makes_no_request_at_all() {
|
fn paging_before_the_first_event_makes_no_request_at_all() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -463,8 +368,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// With nothing older cached there is no floor to give the server, so
|
|
||||||
/// the request carries no `after` at all.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
|
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -475,7 +378,7 @@ mod tests {
|
|||||||
|
|
||||||
let transport2 = ScriptedTransport::default();
|
let transport2 = ScriptedTransport::default();
|
||||||
transport2.respond(200, format!("[{}]", status_line(3)));
|
transport2.respond(200, format!("[{}]", status_line(3)));
|
||||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||||
source2.page(5, 10, true).unwrap();
|
source2.page(5, 10, true).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -484,17 +387,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The half the test above cannot show: when the cache *does* hold an
|
|
||||||
/// older run, the fetch is floored at its end, or the page would run
|
|
||||||
/// straight past it and overlap -- which `store_page` then refuses,
|
|
||||||
/// silently costing the phone the page it just paid for.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
// A stored page covering [3, 6) and two live events above it, so the run this
|
|
||||||
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
|
|
||||||
// cache reads the directory as damaged and discards it.
|
|
||||||
let lines: Vec<String> = (3..6).map(status_line).collect();
|
let lines: Vec<String> = (3..6).map(status_line).collect();
|
||||||
assert!(cache.store_page(&lines, 3, 6, true));
|
assert!(cache.store_page(&lines, 3, 6, true));
|
||||||
cache.append(&status_line(6), 6);
|
cache.append(&status_line(6), 6);
|
||||||
@@ -512,9 +408,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A page the server could not answer is an error, never an empty
|
|
||||||
/// page: the caller would read the second as "this conversation has no
|
|
||||||
/// more history" and stop paging for good.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
|
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -524,12 +417,10 @@ mod tests {
|
|||||||
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
|
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cached line this build cannot read is told apart from the network
|
|
||||||
/// failing, for the same reason: neither is "no more history".
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
cache.store_page(
|
cache.store_page(
|
||||||
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
|
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
|
||||||
3,
|
3,
|
||||||
@@ -551,7 +442,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
|
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
cache.append("not json at all", 1);
|
cache.append("not json at all", 1);
|
||||||
cache.flush();
|
cache.flush();
|
||||||
let transport = ScriptedTransport::default();
|
let transport = ScriptedTransport::default();
|
||||||
@@ -1,47 +1,6 @@
|
|||||||
//! RUST.md's E4: a session list on the left, `transcript-ui`'s screen (I5)
|
use crate::client::api::{ApiClient, SessionSummary, UreqTransport};
|
||||||
//! filling the rest, both against a real `ai-server` reached through
|
use crate::client::event_stream::{StreamItem, follow_session_events};
|
||||||
//! `client-core`. The layout is the simplest thing that shows both at
|
use crate::client::transcript_fold::{
|
||||||
//! once -- a fixed-width column and `rest(1)` for everything else, using
|
|
||||||
//! `iris::widget::{Span, WidgetPtr}` the way `tabs-ui` already switches
|
|
||||||
//! panes, rather than anything desktop-specific:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! +-----------+--------------------------------------+
|
|
||||||
//! | session | transcript_ui::TranscriptScreen |
|
|
||||||
//! | list | (List of folded rows + composer) |
|
|
||||||
//! | (WidgetPtr| |
|
|
||||||
//! | swapped | (WidgetPtr swapped whole on session |
|
|
||||||
//! | on data) | switch or a new transcript event) |
|
|
||||||
//! +-----------+--------------------------------------+
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! **Incoming SSE events go through `TranscriptScreen::apply`**, not a
|
|
||||||
//! full rebuild: `client_core::transcript_fold::fold_event` folds the new
|
|
||||||
//! item list as before, then `apply` updates only the row(s) that actually
|
|
||||||
//! changed (almost always the one still-open assistant message a delta
|
|
||||||
//! landed in) instead of rebuilding the whole right-hand widget tree from
|
|
||||||
//! scratch. `rebuild_transcript` still runs the whole tree once, for a
|
|
||||||
//! freshly loaded/selected session and for `apply`'s own rare
|
|
||||||
//! full-rebuild fallback (a `group_tool_runs` regroup touching a row
|
|
||||||
//! before the tail). The composer's in-progress text survives a rebuild
|
|
||||||
//! (`rebuild_transcript`'s `in_progress` local) since the user typing a
|
|
||||||
//! followup while a reply streams in is the one case a naive rebuild
|
|
||||||
//! would otherwise lose data on -- `apply`'s own path never touches the
|
|
||||||
//! composer at all, so this only matters on the fallback.
|
|
||||||
//!
|
|
||||||
//! Background network I/O (`client_core::api`/`event_stream`, both
|
|
||||||
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
|
|
||||||
//! `std::thread`s that report back through `winit`'s `EventLoopProxy`
|
|
||||||
//! (`Proxy<AppEvent>`), rather than through iris's own `Tasks`/`task_on`:
|
|
||||||
//! `Tasks` only requests a redraw once, after its whole async closure
|
|
||||||
//! finishes, which fits a single request-then-update but not a live SSE
|
|
||||||
//! loop that needs to be seen redrawing after *each* event it relays.
|
|
||||||
//! `Proxy::send_event` wakes the window's event loop immediately, once per
|
|
||||||
//! event, which is what a stream wants.
|
|
||||||
|
|
||||||
use client_core::api::{ApiClient, SessionSummary, UreqTransport};
|
|
||||||
use client_core::event_stream::{StreamItem, follow_session_events};
|
|
||||||
use client_core::transcript_fold::{
|
|
||||||
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
|
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
|
||||||
};
|
};
|
||||||
use event_model::SeqEvent;
|
use event_model::SeqEvent;
|
||||||
@@ -49,18 +8,8 @@ use iris::prelude::*;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
/// The session list column's width -- a fixed size for the simplest
|
|
||||||
/// layout that shows both panels at once (UI_RULES's text-truncation and
|
|
||||||
/// no-shrink rules apply to what's drawn inside it, not to this choice of
|
|
||||||
/// column width itself).
|
|
||||||
const LIST_WIDTH: f32 = 260.0;
|
const LIST_WIDTH: f32 = 260.0;
|
||||||
|
|
||||||
/// Everything a background thread hands back to the window's event loop.
|
|
||||||
/// `generation` on the session-scoped variants is the generation
|
|
||||||
/// `select_session` was on when the thread started (`Client::generation`)
|
|
||||||
/// -- compared back against the current one before being applied, so a
|
|
||||||
/// slow response from a session the reader has since clicked away from
|
|
||||||
/// can't overwrite what replaced it.
|
|
||||||
enum AppEvent {
|
enum AppEvent {
|
||||||
Sessions(Result<Vec<SessionSummary>, String>),
|
Sessions(Result<Vec<SessionSummary>, String>),
|
||||||
TranscriptLoaded {
|
TranscriptLoaded {
|
||||||
@@ -89,13 +38,6 @@ pub fn run() {
|
|||||||
struct Client {
|
struct Client {
|
||||||
ui_state: DefaultUiState,
|
ui_state: DefaultUiState,
|
||||||
api: Arc<ApiClient<UreqTransport>>,
|
api: Arc<ApiClient<UreqTransport>>,
|
||||||
/// A second, independent `UreqTransport` to the same server, used only
|
|
||||||
/// by `select_session`'s live-follow loop. `ApiClient` keeps its
|
|
||||||
/// transport private (rightly -- nothing outside it should reach past
|
|
||||||
/// the typed calls), so a caller that also needs the raw
|
|
||||||
/// `Transport::stream` for SSE, as this one does, builds its own
|
|
||||||
/// rather than the crate growing a getter whose only purpose would be
|
|
||||||
/// letting one caller reach around its own abstraction.
|
|
||||||
stream_transport: Arc<UreqTransport>,
|
stream_transport: Arc<UreqTransport>,
|
||||||
proxy: Proxy<AppEvent>,
|
proxy: Proxy<AppEvent>,
|
||||||
sessions: Vec<SessionSummary>,
|
sessions: Vec<SessionSummary>,
|
||||||
@@ -103,9 +45,7 @@ struct Client {
|
|||||||
items: Vec<TranscriptItem>,
|
items: Vec<TranscriptItem>,
|
||||||
list_ptr: WeakWidget<WidgetPtr>,
|
list_ptr: WeakWidget<WidgetPtr>,
|
||||||
transcript_ptr: WeakWidget<WidgetPtr>,
|
transcript_ptr: WeakWidget<WidgetPtr>,
|
||||||
screen: Option<transcript_ui::TranscriptScreen>,
|
screen: Option<crate::ui::TranscriptScreen>,
|
||||||
/// Bumped every time the selected session changes; see `AppEvent`'s
|
|
||||||
/// doc for what it guards against.
|
|
||||||
generation: Arc<AtomicU64>,
|
generation: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,13 +57,7 @@ impl DefaultAppState for Client {
|
|||||||
rsc: &mut DefaultRsc<Self>,
|
rsc: &mut DefaultRsc<Self>,
|
||||||
proxy: Proxy<AppEvent>,
|
proxy: Proxy<AppEvent>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Re-validated here rather than threaded through from `main` --
|
let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| {
|
||||||
// `DefaultApp::run()` takes no payload, so there is no other way
|
|
||||||
// to get `main`'s parsed CLI/config into this constructor. `main`
|
|
||||||
// already called this once to fail fast before a window opens;
|
|
||||||
// this call only fails if the filesystem changed underneath the
|
|
||||||
// process in between, which is not a case worth a nicer message.
|
|
||||||
let (server, ca_pem) = crate::load_startup_config().unwrap_or_else(|e| {
|
|
||||||
eprintln!("desktop-app: {e}");
|
eprintln!("desktop-app: {e}");
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
});
|
});
|
||||||
@@ -237,9 +171,6 @@ impl Client {
|
|||||||
&& self.generation.load(Ordering::SeqCst) == generation
|
&& self.generation.load(Ordering::SeqCst) == generation
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the right-hand panel with a line of text -- built before
|
|
||||||
/// `transcript_ptr` is reached for, since building the message and
|
|
||||||
/// swapping it in both need `rsc` and can't overlap as one borrow.
|
|
||||||
fn show_message(&mut self, rsc: &mut DefaultRsc<Self>, message: &str) {
|
fn show_message(&mut self, rsc: &mut DefaultRsc<Self>, message: &str) {
|
||||||
let widget = placeholder(rsc, message);
|
let widget = placeholder(rsc, message);
|
||||||
(self.transcript_ptr)(rsc).set(widget);
|
(self.transcript_ptr)(rsc).set(widget);
|
||||||
@@ -262,17 +193,12 @@ impl Client {
|
|||||||
list(rsc).push(row);
|
list(rsc).push(row);
|
||||||
}
|
}
|
||||||
let tree = list
|
let tree = list
|
||||||
.background(rect(Color::rgb(24, 24, 28)))
|
.background(rect(Srgba8::rgb(24, 24, 28)))
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any();
|
.any();
|
||||||
(self.list_ptr)(rsc).set(tree);
|
(self.list_ptr)(rsc).set(tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selecting a session starts a fresh generation: any thread still
|
|
||||||
/// working for the previous one checks `Client::current` before
|
|
||||||
/// touching state, so a slow response for a session the reader has
|
|
||||||
/// clicked away from is silently dropped rather than overwriting what
|
|
||||||
/// replaced it.
|
|
||||||
fn select_session(&mut self, rsc: &mut DefaultRsc<Self>, session_id: String) {
|
fn select_session(&mut self, rsc: &mut DefaultRsc<Self>, session_id: String) {
|
||||||
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
self.selected = Some(session_id.clone());
|
self.selected = Some(session_id.clone());
|
||||||
@@ -286,24 +212,9 @@ impl Client {
|
|||||||
let proxy = self.proxy.clone();
|
let proxy = self.proxy.clone();
|
||||||
let live_generation = self.generation.clone();
|
let live_generation = self.generation.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
// The most recent 200 events, coalesced -- plenty for a
|
|
||||||
// desktop proof; RUST.md's I3/history-paging work is what a
|
|
||||||
// real scrollback would reuse, out of scope here (E4 is only
|
|
||||||
// "the same screen runs in a window").
|
|
||||||
let page: Result<Vec<serde_json::Value>, String> = api
|
let page: Result<Vec<serde_json::Value>, String> = api
|
||||||
.fetch_transcript_page(&session_id, None, 200, true)
|
.fetch_transcript_page(&session_id, None, 200, true)
|
||||||
.map_err(|e| e.to_string());
|
.map_err(|e| e.to_string());
|
||||||
// The raw wire `seq` of the last line fetched -- not the seq of
|
|
||||||
// the last *folded item*. A `TranscriptItem::AssistantMsg` keeps
|
|
||||||
// the seq of the first delta it accumulated (`fold_event`'s own
|
|
||||||
// doc: "a row whose identity changed with every delta would be
|
|
||||||
// a new row every frame"), so resuming the live stream from
|
|
||||||
// that seq re-delivers every delta already folded into it,
|
|
||||||
// duplicating the tail of whatever reply was mid-stream when
|
|
||||||
// the page was fetched. Found by screenshotting a real reply
|
|
||||||
// through `run-headless.sh`: the assistant's line read "You
|
|
||||||
// said: ... testsaid: ... test", the back half being deltas 2
|
|
||||||
// through N replayed onto an already-complete message.
|
|
||||||
let after = page
|
let after = page
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok()
|
.ok()
|
||||||
@@ -316,9 +227,6 @@ impl Client {
|
|||||||
result,
|
result,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Follows live from here in the same thread -- sequential
|
|
||||||
// rather than a second thread, since there is nothing to do
|
|
||||||
// with the stream until the page above has been sent anyway.
|
|
||||||
let stop = || live_generation.load(Ordering::SeqCst) != generation;
|
let stop = || live_generation.load(Ordering::SeqCst) != generation;
|
||||||
if stop() {
|
if stop() {
|
||||||
return;
|
return;
|
||||||
@@ -364,7 +272,7 @@ impl Client {
|
|||||||
.filter(|t| !t.is_empty());
|
.filter(|t| !t.is_empty());
|
||||||
|
|
||||||
let rows = group_tool_runs(&self.items);
|
let rows = group_tool_runs(&self.items);
|
||||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
let (screen, tree) = crate::ui::build_tree(rsc, rows);
|
||||||
|
|
||||||
if let Some(text) = in_progress {
|
if let Some(text) = in_progress {
|
||||||
screen.composer.field.edit(rsc).set(&text);
|
screen.composer.field.edit(rsc).set(&text);
|
||||||
@@ -385,22 +293,20 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row in the session list: title on top, status below, highlighted
|
|
||||||
/// when it's the one currently shown.
|
|
||||||
fn session_row(
|
fn session_row(
|
||||||
rsc: &mut DefaultRsc<Client>,
|
rsc: &mut DefaultRsc<Client>,
|
||||||
session: &SessionSummary,
|
session: &SessionSummary,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
) -> StrongWidget {
|
) -> StrongWidget {
|
||||||
let bg = if selected {
|
let bg = if selected {
|
||||||
Color::rgb(58, 90, 138)
|
Srgba8::rgb(58, 90, 138)
|
||||||
} else {
|
} else {
|
||||||
Color::rgb(38, 38, 44)
|
Srgba8::rgb(38, 38, 44)
|
||||||
};
|
};
|
||||||
let id = session.id.clone();
|
let id = session.id.clone();
|
||||||
let label = format!("{}\n{}", session.title, session.status);
|
let label = format!("{}\n{}", session.title, session.status);
|
||||||
wtext(label)
|
wtext(label)
|
||||||
.color(Color::WHITE)
|
.color(PaintId::WHITE)
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.pad(10)
|
.pad(10)
|
||||||
.width(rest(1))
|
.width(rest(1))
|
||||||
@@ -417,7 +323,7 @@ fn session_row(
|
|||||||
|
|
||||||
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
|
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
|
||||||
wtext(message.to_string())
|
wtext(message.to_string())
|
||||||
.color(Color::WHITE)
|
.color(PaintId::WHITE)
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.pad(16)
|
.pad(16)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -1,23 +1,3 @@
|
|||||||
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
|
|
||||||
//! package comment for what this crate is and RUST.md's E3 entry for the
|
|
||||||
//! design decisions.
|
|
||||||
//!
|
|
||||||
//! Each native method is declared with `jni`'s [`native_method!`] macro
|
|
||||||
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
|
|
||||||
//! the macro derives the mangled export name and the JNI signature from the
|
|
||||||
//! Rust function itself, so the two cannot drift apart the way a
|
|
||||||
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
|
|
||||||
//! routinely do. `error_policy = LogErrorAndDefault` matches
|
|
||||||
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
|
|
||||||
//! JNI call that threw) is reported to logcat, not thrown back into Java
|
|
||||||
//! as an exception that would crash the app over something recoverable.
|
|
||||||
//!
|
|
||||||
//! Each `const _: NativeMethod = native_method! { ... };` binding is
|
|
||||||
//! otherwise unused by name -- `_` is the idiomatic way to keep a
|
|
||||||
//! side-effecting const (here, generating the `#[export_name]`d function
|
|
||||||
//! the JVM resolves by the JNI naming convention) without a `dead_code`
|
|
||||||
//! warning for a binding nothing reads.
|
|
||||||
|
|
||||||
mod jcall;
|
mod jcall;
|
||||||
mod notify;
|
mod notify;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -28,15 +8,6 @@ use jni::objects::{JClass, JObject};
|
|||||||
use jni::sys::jint;
|
use jni::sys::jint;
|
||||||
use jni::{Env, NativeMethod, native_method};
|
use jni::{Env, NativeMethod, native_method};
|
||||||
|
|
||||||
/// Installs the `log` backend that routes to logcat, once per process.
|
|
||||||
/// Without it, `LogErrorAndDefault` (every native method below) and any
|
|
||||||
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
|
|
||||||
/// call into the `log` facade's default no-op logger, and a real failure
|
|
||||||
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
|
|
||||||
/// crashing, since nothing on screen or in the log says a notification was
|
|
||||||
/// dropped. Called from every entry point below rather than a Java-side
|
|
||||||
/// `Application.onCreate`, since this crate deliberately has no such class
|
|
||||||
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
|
|
||||||
fn ensure_logger() {
|
fn ensure_logger() {
|
||||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||||
ONCE.call_once(|| {
|
ONCE.call_once(|| {
|
||||||
@@ -65,8 +36,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
error_policy = LogErrorAndDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
|
|
||||||
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
|
|
||||||
fn native_handle_intent<'local>(
|
fn native_handle_intent<'local>(
|
||||||
env: &mut Env<'local>,
|
env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -84,9 +53,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
error_policy = LogErrorAndDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
|
|
||||||
/// enrollment may have just landed) and from `NotificationService.sync`
|
|
||||||
/// itself. See `notify::sync`.
|
|
||||||
fn native_sync<'local>(
|
fn native_sync<'local>(
|
||||||
env: &mut Env<'local>,
|
env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -103,7 +69,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
error_policy = LogErrorAndDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
|
|
||||||
fn native_on_start_command<'local>(
|
fn native_on_start_command<'local>(
|
||||||
env: &mut Env<'local>,
|
env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -120,7 +85,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
error_policy = LogErrorAndDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
|
|
||||||
fn native_on_destroy<'local>(
|
fn native_on_destroy<'local>(
|
||||||
_env: &mut Env<'local>,
|
_env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -12,20 +12,19 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client_core::api::UreqTransport;
|
use crate::client::api::UreqTransport;
|
||||||
use client_core::notifications::{SessionNotification, follow_notifications};
|
use crate::client::notifications::{SessionNotification, follow_notifications};
|
||||||
use jni::Env;
|
use jni::Env;
|
||||||
use jni::errors::Result;
|
use jni::errors::Result;
|
||||||
use jni::objects::{JObject, JValue};
|
use jni::objects::{JObject, JValue};
|
||||||
use jni::sys::{JNI_TRUE, jint};
|
use jni::sys::{JNI_TRUE, jint};
|
||||||
|
|
||||||
use crate::settings::{self, ServerSettings};
|
use crate::shell::settings::{self, ServerSettings};
|
||||||
|
|
||||||
const ALERT_CHANNEL: &str = "sessions";
|
const ALERT_CHANNEL: &str = "sessions";
|
||||||
const ONGOING_CHANNEL: &str = "connection";
|
const ONGOING_CHANNEL: &str = "connection";
|
||||||
const ONGOING_ID: i32 = 1;
|
const ONGOING_ID: i32 = 1;
|
||||||
const ALERT_ID: i32 = 2;
|
const ALERT_ID: i32 = 2;
|
||||||
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
|
|
||||||
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
||||||
|
|
||||||
/// Whether the follow-loop thread is already running. **A deviation from
|
/// Whether the follow-loop thread is already running. **A deviation from
|
||||||
@@ -45,25 +44,14 @@ const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
|||||||
/// same guard back to `Notifications.kt` separately.
|
/// same guard back to `Notifications.kt` separately.
|
||||||
static RUNNING: AtomicBool = AtomicBool::new(false);
|
static RUNNING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
/// Set by `nativeOnDestroy`, checked by the follow loop between
|
|
||||||
/// reconnects. **Known gap, recorded rather than hidden**: unlike
|
|
||||||
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
|
|
||||||
/// can interrupt a `ureq` read already blocked inside one connection --
|
|
||||||
/// `Transport::stream` hands back a plain `Read` with no cancellation
|
|
||||||
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
|
|
||||||
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
|
|
||||||
/// practice this is a bounded wait rather than a hang; closing that gap
|
|
||||||
/// for real means adding a cancellation point to `client_core::Transport`,
|
|
||||||
/// which is a decision affecting every caller of that trait, not just this
|
|
||||||
/// one -- left for whoever next depends on prompt shutdown.
|
|
||||||
static STOPPING: AtomicBool = AtomicBool::new(false);
|
static STOPPING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
||||||
crate::jcall::get_static_field(env, class, field, "I")?.i()
|
crate::shell::jcall::get_static_field(env, class, field, "I")?.i()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationManagerCompat",
|
"androidx/core/app/NotificationManagerCompat",
|
||||||
"from",
|
"from",
|
||||||
@@ -80,22 +68,22 @@ fn create_channel(
|
|||||||
name: &str,
|
name: &str,
|
||||||
importance: i32,
|
importance: i32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let id_j = crate::jcall::jstr_obj(env, id)?;
|
let id_j = crate::shell::jcall::jstr_obj(env, id)?;
|
||||||
let builder = crate::jcall::new_object(
|
let builder = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationChannelCompat$Builder",
|
"androidx/core/app/NotificationChannelCompat$Builder",
|
||||||
"(Ljava/lang/String;I)V",
|
"(Ljava/lang/String;I)V",
|
||||||
&[JValue::Object(&id_j), JValue::Int(importance)],
|
&[JValue::Object(&id_j), JValue::Int(importance)],
|
||||||
)?;
|
)?;
|
||||||
let name_j = crate::jcall::jstr_obj(env, name)?;
|
let name_j = crate::shell::jcall::jstr_obj(env, name)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"setName",
|
"setName",
|
||||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
||||||
&[JValue::Object(&name_j)],
|
&[JValue::Object(&name_j)],
|
||||||
)?;
|
)?;
|
||||||
let channel = crate::jcall::call_method(
|
let channel = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"build",
|
"build",
|
||||||
@@ -103,7 +91,7 @@ fn create_channel(
|
|||||||
&[],
|
&[],
|
||||||
)?
|
)?
|
||||||
.l()?;
|
.l()?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
manager,
|
manager,
|
||||||
"createNotificationChannel",
|
"createNotificationChannel",
|
||||||
@@ -146,8 +134,8 @@ fn new_intent_for<'l>(
|
|||||||
context: &JObject,
|
context: &JObject,
|
||||||
class_name: &str,
|
class_name: &str,
|
||||||
) -> Result<JObject<'l>> {
|
) -> Result<JObject<'l>> {
|
||||||
let target_class = crate::jcall::find_class(env, class_name)?;
|
let target_class = crate::shell::jcall::find_class(env, class_name)?;
|
||||||
crate::jcall::new_object(
|
crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"android/content/Intent",
|
"android/content/Intent",
|
||||||
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
||||||
@@ -165,41 +153,42 @@ fn session_intent<'l>(
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> Result<JObject<'l>> {
|
) -> Result<JObject<'l>> {
|
||||||
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
||||||
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
let action_view = crate::shell::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&intent,
|
&intent,
|
||||||
"setAction",
|
"setAction",
|
||||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||||
&[JValue::Object(&action_view)],
|
&[JValue::Object(&action_view)],
|
||||||
)?;
|
)?;
|
||||||
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
let builder = crate::shell::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
||||||
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
|
let scheme = crate::shell::jcall::jstr_obj(env, settings::SCHEME)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"scheme",
|
"scheme",
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
&[JValue::Object(&scheme)],
|
&[JValue::Object(&scheme)],
|
||||||
)?;
|
)?;
|
||||||
let authority = crate::jcall::jstr_obj(env, "session")?;
|
let authority = crate::shell::jcall::jstr_obj(env, "session")?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"authority",
|
"authority",
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
&[JValue::Object(&authority)],
|
&[JValue::Object(&authority)],
|
||||||
)?;
|
)?;
|
||||||
let path = crate::jcall::jstr_obj(env, session_id)?;
|
let path = crate::shell::jcall::jstr_obj(env, session_id)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"appendPath",
|
"appendPath",
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
&[JValue::Object(&path)],
|
&[JValue::Object(&path)],
|
||||||
)?;
|
)?;
|
||||||
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
|
let uri = crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?
|
||||||
crate::jcall::call_method(
|
.l()?;
|
||||||
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&intent,
|
&intent,
|
||||||
"setData",
|
"setData",
|
||||||
@@ -216,7 +205,7 @@ fn pending_activity<'l>(
|
|||||||
) -> Result<JObject<'l>> {
|
) -> Result<JObject<'l>> {
|
||||||
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
||||||
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"android/app/PendingIntent",
|
"android/app/PendingIntent",
|
||||||
"getActivity",
|
"getActivity",
|
||||||
@@ -238,7 +227,7 @@ fn builder_call<'l>(
|
|||||||
sig: &str,
|
sig: &str,
|
||||||
args: &[JValue],
|
args: &[JValue],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
crate::jcall::call_method(env, builder, method, sig, args)?;
|
crate::shell::jcall::call_method(env, builder, method, sig, args)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,14 +248,14 @@ fn foreground_type(env: &mut Env) -> Result<i32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||||
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
let channel = crate::shell::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
||||||
let builder = crate::jcall::new_object(
|
let builder = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationCompat$Builder",
|
"androidx/core/app/NotificationCompat$Builder",
|
||||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||||
&[JValue::Object(context), JValue::Object(&channel)],
|
&[JValue::Object(context), JValue::Object(&channel)],
|
||||||
)?;
|
)?;
|
||||||
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
let title = crate::shell::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
||||||
builder_call(
|
builder_call(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
@@ -297,7 +286,8 @@ fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObj
|
|||||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
&[JValue::Int(priority_min)],
|
&[JValue::Int(priority_min)],
|
||||||
)?;
|
)?;
|
||||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l()
|
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
|
/// Starts the service if there is a server to connect to, and stops it
|
||||||
@@ -306,7 +296,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
|||||||
let service_intent =
|
let service_intent =
|
||||||
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
||||||
if settings::load(env, context)?.is_none() {
|
if settings::load(env, context)?.is_none() {
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
context,
|
context,
|
||||||
"stopService",
|
"stopService",
|
||||||
@@ -316,7 +306,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
create_channels(env, context)?;
|
create_channels(env, context)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/content/ContextCompat",
|
"androidx/core/content/ContextCompat",
|
||||||
"startForegroundService",
|
"startForegroundService",
|
||||||
@@ -326,16 +316,11 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `Service.onStartCommand` body -- loads settings, starts the
|
|
||||||
/// foreground notification, and spawns the follow-loop thread. Answers the
|
|
||||||
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
|
|
||||||
/// framework rather than hardcoded so a wrong guess at their values cannot
|
|
||||||
/// silently pick the other behaviour.
|
|
||||||
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
|
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
|
||||||
match try_start(env, &service) {
|
match try_start(env, &service) {
|
||||||
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
let _ = crate::shell::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
||||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -352,7 +337,7 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
|||||||
let ca = settings::load_pinned_ca(env)?;
|
let ca = settings::load_pinned_ca(env)?;
|
||||||
let notification = ongoing_notification(env, service)?;
|
let notification = ongoing_notification(env, service)?;
|
||||||
let fg_type = foreground_type(env)?;
|
let fg_type = foreground_type(env)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/ServiceCompat",
|
"androidx/core/app/ServiceCompat",
|
||||||
"startForeground",
|
"startForeground",
|
||||||
@@ -379,9 +364,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
|||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("ai-app-notifications".to_string())
|
.name("ai-app-notifications".to_string())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
// Requests a *permanent* attachment (detached only when this thread
|
|
||||||
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
|
|
||||||
// this is the long-lived follow loop, not a one-shot callback.
|
|
||||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||||
follow_loop(env, &context, settings, &ca);
|
follow_loop(env, &context, settings, &ca);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -391,12 +373,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Follows the backend's notification stream, reconnecting until stopped
|
|
||||||
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
|
|
||||||
/// ordinary case, so it retries quietly and forever; nothing is shown when
|
|
||||||
/// it cannot connect, for the same reason as the Kotlin original: a
|
|
||||||
/// notification saying "I could not tell you whether anything happened" is
|
|
||||||
/// noise about a condition nobody can act on.
|
|
||||||
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
||||||
while !STOPPING.load(Ordering::SeqCst) {
|
while !STOPPING.load(Ordering::SeqCst) {
|
||||||
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
||||||
@@ -414,9 +390,6 @@ fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One notification per session, replacing that session's previous one --
|
|
||||||
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
|
|
||||||
/// branches this module's doc comment explains.
|
|
||||||
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
|
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
|
||||||
let manager = notification_manager(env, context)?;
|
let manager = notification_manager(env, context)?;
|
||||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||||
@@ -424,13 +397,14 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
let allowed = if sdk < tiramisu {
|
let allowed = if sdk < tiramisu {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
let permission =
|
||||||
|
crate::shell::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
||||||
let granted = static_int(
|
let granted = static_int(
|
||||||
env,
|
env,
|
||||||
"android/content/pm/PackageManager",
|
"android/content/pm/PackageManager",
|
||||||
"PERMISSION_GRANTED",
|
"PERMISSION_GRANTED",
|
||||||
)?;
|
)?;
|
||||||
let result = crate::jcall::call_static_method(
|
let result = crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/content/ContextCompat",
|
"androidx/core/content/ContextCompat",
|
||||||
"checkSelfPermission",
|
"checkSelfPermission",
|
||||||
@@ -441,20 +415,21 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
result == granted
|
result == granted
|
||||||
};
|
};
|
||||||
let enabled =
|
let enabled =
|
||||||
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
|
crate::shell::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?
|
||||||
|
.z()?;
|
||||||
if !allowed || !enabled {
|
if !allowed || !enabled {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let intent = session_intent(env, context, ¬ification.session_id)?;
|
let intent = session_intent(env, context, ¬ification.session_id)?;
|
||||||
let pending = pending_activity(env, context, &intent)?;
|
let pending = pending_activity(env, context, &intent)?;
|
||||||
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
let channel = crate::shell::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
||||||
let builder = crate::jcall::new_object(
|
let builder = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationCompat$Builder",
|
"androidx/core/app/NotificationCompat$Builder",
|
||||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||||
&[JValue::Object(context), JValue::Object(&channel)],
|
&[JValue::Object(context), JValue::Object(&channel)],
|
||||||
)?;
|
)?;
|
||||||
let title = crate::jcall::jstr_obj(env, ¬ification.title)?;
|
let title = crate::shell::jcall::jstr_obj(env, ¬ification.title)?;
|
||||||
builder_call(
|
builder_call(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
@@ -462,7 +437,7 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
&[JValue::Object(&title)],
|
&[JValue::Object(&title)],
|
||||||
)?;
|
)?;
|
||||||
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
let text = crate::shell::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
||||||
builder_call(
|
builder_call(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
@@ -507,11 +482,16 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
&[JValue::Bool(JNI_TRUE)],
|
&[JValue::Bool(JNI_TRUE)],
|
||||||
)?;
|
)?;
|
||||||
let built =
|
let built = crate::shell::jcall::call_method(
|
||||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
env,
|
||||||
|
&builder,
|
||||||
|
"build",
|
||||||
|
"()Landroid/app/Notification;",
|
||||||
|
&[],
|
||||||
|
)?
|
||||||
.l()?;
|
.l()?;
|
||||||
let tag = crate::jcall::jstr_obj(env, ¬ification.session_id)?;
|
let tag = crate::shell::jcall::jstr_obj(env, ¬ification.session_id)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&manager,
|
&manager,
|
||||||
"notify",
|
"notify",
|
||||||
@@ -525,8 +505,6 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
|
|
||||||
/// the gap this module's `STOPPING` doc explains.
|
|
||||||
pub fn on_destroy() {
|
pub fn on_destroy() {
|
||||||
STOPPING.store(true, Ordering::SeqCst);
|
STOPPING.store(true, Ordering::SeqCst);
|
||||||
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
||||||
@@ -542,9 +520,9 @@ pub fn on_destroy() {
|
|||||||
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
||||||
let message = format!("android-shell: {where_}: {error}");
|
let message = format!("android-shell: {where_}: {error}");
|
||||||
let _ = (|| -> Result<()> {
|
let _ = (|| -> Result<()> {
|
||||||
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
|
let tag = crate::shell::jcall::jstr_obj(env, "android-shell")?;
|
||||||
let msg = crate::jcall::jstr_obj(env, &message)?;
|
let msg = crate::shell::jcall::jstr_obj(env, &message)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"android/util/Log",
|
"android/util/Log",
|
||||||
"e",
|
"e",
|
||||||
@@ -28,20 +28,15 @@ impl ServerSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This experiment's own scheme and Keystore alias -- distinct from the
|
|
||||||
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
|
|
||||||
/// installed side by side on the same development device without
|
|
||||||
/// colliding over which one a scanned QR or a deep link resolves to. See
|
|
||||||
/// RUST.md's E3 entry for why they are not the same value.
|
|
||||||
pub(crate) const SCHEME: &str = "aiappshell";
|
pub(crate) const SCHEME: &str = "aiappshell";
|
||||||
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
||||||
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
||||||
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
||||||
|
|
||||||
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
||||||
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
|
let scheme = crate::shell::jcall::jstr_obj(env, SCHEME)?;
|
||||||
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
|
let alias = crate::shell::jcall::jstr_obj(env, KEY_ALIAS)?;
|
||||||
crate::jcall::new_object(
|
crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
STORE_CLASS,
|
STORE_CLASS,
|
||||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||||
@@ -51,13 +46,14 @@ fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
|||||||
|
|
||||||
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
||||||
let host = get_string(env, settings_obj, "getHost")?;
|
let host = get_string(env, settings_obj, "getHost")?;
|
||||||
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
let port = crate::shell::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
||||||
let token = get_string(env, settings_obj, "getToken")?;
|
let token = get_string(env, settings_obj, "getToken")?;
|
||||||
Ok(ServerSettings { host, port, token })
|
Ok(ServerSettings { host, port, token })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
||||||
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
let value =
|
||||||
|
crate::shell::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
||||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||||
jstr.try_to_string(env)
|
jstr.try_to_string(env)
|
||||||
}
|
}
|
||||||
@@ -66,7 +62,7 @@ fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
|||||||
/// `ServerConfig.kt`'s `loadServerSettings`.
|
/// `ServerConfig.kt`'s `loadServerSettings`.
|
||||||
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
||||||
let store = new_store(env)?;
|
let store = new_store(env)?;
|
||||||
let settings_obj = crate::jcall::call_method(
|
let settings_obj = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&store,
|
&store,
|
||||||
"load",
|
"load",
|
||||||
@@ -80,12 +76,11 @@ pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>>
|
|||||||
Ok(Some(read_settings(env, &settings_obj)?))
|
Ok(Some(read_settings(env, &settings_obj)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
|
|
||||||
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
||||||
let store = new_store(env)?;
|
let store = new_store(env)?;
|
||||||
let host = crate::jcall::jstr_obj(env, &settings.host)?;
|
let host = crate::shell::jcall::jstr_obj(env, &settings.host)?;
|
||||||
let token = crate::jcall::jstr_obj(env, &settings.token)?;
|
let token = crate::shell::jcall::jstr_obj(env, &settings.token)?;
|
||||||
let settings_obj = crate::jcall::new_object(
|
let settings_obj = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
SETTINGS_CLASS,
|
SETTINGS_CLASS,
|
||||||
"(Ljava/lang/String;ILjava/lang/String;)V",
|
"(Ljava/lang/String;ILjava/lang/String;)V",
|
||||||
@@ -95,7 +90,7 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
|
|||||||
JValue::Object(&token),
|
JValue::Object(&token),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&store,
|
&store,
|
||||||
"save",
|
"save",
|
||||||
@@ -105,12 +100,9 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
|
|
||||||
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
|
|
||||||
/// query-parameter rules rather than re-deriving them here.
|
|
||||||
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
||||||
let store = new_store(env)?;
|
let store = new_store(env)?;
|
||||||
let settings_obj = crate::jcall::call_method(
|
let settings_obj = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&store,
|
&store,
|
||||||
"parseEnrollmentUri",
|
"parseEnrollmentUri",
|
||||||
@@ -129,7 +121,7 @@ pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<Serve
|
|||||||
/// but into a plain Java constant, since this module has no Kotlin of its
|
/// but into a plain Java constant, since this module has no Kotlin of its
|
||||||
/// own to generate into.
|
/// own to generate into.
|
||||||
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
||||||
let value = crate::jcall::get_static_field(
|
let value = crate::shell::jcall::get_static_field(
|
||||||
env,
|
env,
|
||||||
"com/example/aiapp/shell/PinnedCa",
|
"com/example/aiapp/shell/PinnedCa",
|
||||||
"PINNED_CA_PEM",
|
"PINNED_CA_PEM",
|
||||||
@@ -1,30 +1,10 @@
|
|||||||
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
|
use crate::client::api::{ApiClient, UreqTransport};
|
||||||
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
|
|
||||||
//!
|
|
||||||
//! **Scope cut, recorded rather than silent**: only shared *text*
|
|
||||||
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
|
|
||||||
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
|
|
||||||
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
|
|
||||||
//! ported here, because `client-core`'s `ApiClient` does not have the
|
|
||||||
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
|
|
||||||
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
|
|
||||||
//! `content://` stream and no text falls through to a toast saying so,
|
|
||||||
//! rather than silently doing nothing. Closing this gap is the same
|
|
||||||
//! `client-core` work whichever caller needs it next.
|
|
||||||
//!
|
|
||||||
//! **Which session a share lands in** is also a placeholder: with no
|
|
||||||
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
|
|
||||||
//! to whichever session has the latest `last_activity` -- the one most
|
|
||||||
//! likely to be what somebody meant. Worth revisiting once a real screen
|
|
||||||
//! exists to ask instead of guessing.
|
|
||||||
|
|
||||||
use client_core::api::{ApiClient, UreqTransport};
|
|
||||||
use jni::Env;
|
use jni::Env;
|
||||||
use jni::errors::Result;
|
use jni::errors::Result;
|
||||||
use jni::objects::{JObject, JString, JValue};
|
use jni::objects::{JObject, JString, JValue};
|
||||||
|
|
||||||
use crate::notify;
|
use crate::shell::notify;
|
||||||
use crate::settings;
|
use crate::shell::settings;
|
||||||
|
|
||||||
const ACTION_SEND: &str = "android.intent.action.SEND";
|
const ACTION_SEND: &str = "android.intent.action.SEND";
|
||||||
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
||||||
@@ -32,7 +12,8 @@ const ACTION_VIEW: &str = "android.intent.action.VIEW";
|
|||||||
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
||||||
|
|
||||||
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
|
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
|
||||||
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
let value =
|
||||||
|
crate::shell::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
||||||
if value.is_null() {
|
if value.is_null() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -41,8 +22,8 @@ fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Optio
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
||||||
let message = crate::jcall::jstr_obj(env, message)?;
|
let message = crate::shell::jcall::jstr_obj(env, message)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"com/example/aiapp/shell/MainActivity",
|
"com/example/aiapp/shell/MainActivity",
|
||||||
"toast",
|
"toast",
|
||||||
@@ -52,8 +33,6 @@ fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one place an incoming intent is sorted into what it means -- mirrors
|
|
||||||
/// `MainActivity.kt`'s `handleIntent`.
|
|
||||||
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||||
let action = get_string_method(env, intent, "getAction")?;
|
let action = get_string_method(env, intent, "getAction")?;
|
||||||
if matches!(
|
if matches!(
|
||||||
@@ -65,7 +44,8 @@ pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Res
|
|||||||
if action.as_deref() != Some(ACTION_VIEW) {
|
if action.as_deref() != Some(ACTION_VIEW) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
|
let uri = crate::shell::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?
|
||||||
|
.l()?;
|
||||||
if uri.is_null() {
|
if uri.is_null() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -84,9 +64,6 @@ fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Resu
|
|||||||
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
// There is no session screen yet (E4's job); the toast is this
|
|
||||||
// experiment's stand-in proof that the tap was routed to the right
|
|
||||||
// session id.
|
|
||||||
toast(env, activity, &format!("Opened session {session_id}"))
|
toast(env, activity, &format!("Opened session {session_id}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +82,9 @@ fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
|
|
||||||
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
|
|
||||||
/// what happens to it, minus attachments per this module's doc comment.
|
|
||||||
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||||
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
let extra_text = crate::shell::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||||
let text = crate::jcall::call_method(
|
let text = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
intent,
|
intent,
|
||||||
"getStringExtra",
|
"getStringExtra",
|
||||||
@@ -1,34 +1,6 @@
|
|||||||
//! The message composer at the bottom of the transcript screen: a
|
use crate::ui::theme::Theme;
|
||||||
//! multi-line editable field with a natural (not fixed) height, so it
|
|
||||||
//! grows as typed into -- IRIS_TODO.md's "input box" benchmark case
|
|
||||||
//! (`iris/benches/message_list.rs` exercises the mechanism in isolation;
|
|
||||||
//! this wires the same `TextEdit`-with-no-`Sized`-wrapper idiom into the
|
|
||||||
//! real screen). `lib.rs` gives the transcript `List` `.height(rest(1))`
|
|
||||||
//! beside this widget in a `Span::down`, so the list's own draw already
|
|
||||||
//! measures whatever vertical space is left each frame -- nothing here
|
|
||||||
//! computes a height by hand, and growing this field is exactly the
|
|
||||||
//! O(1)-move-chain case LAYOUT.md and I3's benchmark already measured.
|
|
||||||
//!
|
|
||||||
//! **Rebuilt 2026-09-06** (Iris's phone report on the dc01f88 build: the
|
|
||||||
//! grey bar drawn as a short, fixed strip with the typed text ~150px below
|
|
||||||
//! it on black, and empty black between the bar and the keyboard). One
|
|
||||||
//! widget now, top to bottom: an opaque background sized to its content
|
|
||||||
//! (`.background`, the same `Stack` idiom the header row's `HEADER_SURFACE`
|
|
||||||
//! already uses), the field inside `dp` padding and capped at
|
|
||||||
//! [`MAX_LINES`] before it scrolls instead of growing forever, and an
|
|
||||||
//! outer [`Pad`] whose `bottom` [`TranscriptScreen::set_bottom_inset`]
|
|
||||||
//! rewrites in place whenever the keyboard opens/closes -- never rebuilt,
|
|
||||||
//! since `field` is strongly owned inside this tree and this crate's
|
|
||||||
//! widgets cannot be re-parented once added (this module's own comment
|
|
||||||
//! below on why `build_composer` hands back a **weak** id).
|
|
||||||
|
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
|
|
||||||
/// Caps the field's growth at roughly six lines of its own 18px text
|
|
||||||
/// before it scrolls instead of consuming the whole screen -- an
|
|
||||||
/// approximation (line-height and padding folded into one round `dp`
|
|
||||||
/// number) rather than a value derived from the font's real metrics,
|
|
||||||
/// which nothing in this crate exposes to a caller today.
|
|
||||||
const MAX_LINES: f32 = 6.0;
|
const MAX_LINES: f32 = 6.0;
|
||||||
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
|
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
|
||||||
const FIELD_PAD_DP: f32 = 12.0;
|
const FIELD_PAD_DP: f32 = 12.0;
|
||||||
@@ -58,6 +30,7 @@ impl Composer {
|
|||||||
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
|
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) {
|
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
|
||||||
pad.padding.bottom = Len::abs(inset);
|
pad.padding.bottom = Len::abs(inset);
|
||||||
|
pad.exact_region = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +42,7 @@ impl Composer {
|
|||||||
/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same
|
/// 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
|
/// mistake this box's `row.rs` first made with its sender-label header, see
|
||||||
/// that file's comment for the fuller account.
|
/// that file's comment for the fuller account.
|
||||||
pub fn build_composer<Rsc: HasEvents>(rsc: &mut Rsc) -> (Composer, WeakWidget)
|
pub fn build_composer<Rsc: HasEvents>(rsc: &mut Rsc, theme: &Theme) -> (Composer, WeakWidget)
|
||||||
where
|
where
|
||||||
Rsc::State: FocusHost,
|
Rsc::State: FocusHost,
|
||||||
{
|
{
|
||||||
@@ -78,22 +51,21 @@ where
|
|||||||
.text_align(Align::LEFT)
|
.text_align(Align::LEFT)
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.size(18)
|
.size(18)
|
||||||
.color(UiColor::WHITE)
|
.color(theme.text.clone())
|
||||||
.attr::<Selectable>(())
|
.attr::<Selectable>(())
|
||||||
.label("Message")
|
.label("Message")
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
// One widget: an opaque bar sized to its own content (`.background`'s
|
// Without any mask at all the overflow paints *above* the bar, over
|
||||||
// `Stack{child: 1}`, the header row's own idiom) wrapping the padded,
|
// the transcript: measured at 58px of stray text for a 475px message
|
||||||
// height-capped field -- not a background rect and a field drawn as
|
// in a 417px box.
|
||||||
// two independent siblings, which is what let the two disagree on
|
|
||||||
// where the bar actually was.
|
|
||||||
let content = field
|
let content = field
|
||||||
|
.width(rest(1))
|
||||||
|
.scrollable(Axis::Y, Pin::End)
|
||||||
.pad(dp(FIELD_PAD_DP))
|
.pad(dp(FIELD_PAD_DP))
|
||||||
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
||||||
.scrollable()
|
|
||||||
.width(rest(1))
|
.width(rest(1))
|
||||||
.background(rect(UiColor::new(40, 40, 46, 255)))
|
.masked_by(rect(theme.composer_surface.clone()))
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
|
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
|
||||||
@@ -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
|
||||||
@@ -280,7 +280,7 @@ fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
|||||||
* One row of `GET /sessions/{id}/subagents`, oldest first.
|
* 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
|
* 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", "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.
|
* 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
|
* 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.
|
* 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)
|
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import android.content.Context
|
|||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): an in-process
|
* P0's benchmark gate (see docs/RUST.md and the 2026-09-05 decision): an in-process fake of the
|
||||||
* fake of the backend, so the `bench` build type can drive a real session screen -- the real
|
* 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.
|
* [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
|
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
|
||||||
@@ -24,7 +24,7 @@ object BenchFixture {
|
|||||||
const val FIXTURE_PORT = 1
|
const val FIXTURE_PORT = 1
|
||||||
|
|
||||||
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
|
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
|
||||||
private const val BACKLOG_COUNT = 3200
|
private const val BACKLOG_COUNT = 3202
|
||||||
|
|
||||||
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
|
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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.
|
* and never something of its own to have merely stopped.
|
||||||
|
|||||||
@@ -295,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
|
// 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.
|
// note opened and scrolled past is still open on the way back.
|
||||||
var openMemories by remember { mutableStateOf(setOf<String>()) }
|
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
|
// 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.
|
// thumbnail: a row regrouped underneath the reader takes its whole subtree with it.
|
||||||
var fullImage by remember { mutableStateOf<String?>(null) }
|
var fullImage by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -382,12 +388,14 @@ fun SessionScreen(
|
|||||||
// Bumped when a cold reply's parses become ready, so the flatten runs again and can split it.
|
// Bumped when a cold reply's parses become ready, so the flatten runs again and can split it.
|
||||||
var warmedTick by remember { mutableIntStateOf(0) }
|
var warmedTick by remember { mutableIntStateOf(0) }
|
||||||
val units =
|
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
|
// 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
|
// 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.
|
// the tick re-flattens, so settling never costs a whole-message parse in a frame.
|
||||||
LaunchedEffect(rows) {
|
LaunchedEffect(rows, shownWholeRows) {
|
||||||
val cold = unwarmedReplies(rows, replies)
|
val cold = unwarmedReplies(rows, replies, shownWholeRows)
|
||||||
if (cold.isNotEmpty()) {
|
if (cold.isNotEmpty()) {
|
||||||
warm(replies, cold)
|
warm(replies, cold)
|
||||||
warmedTick++
|
warmedTick++
|
||||||
@@ -550,6 +558,20 @@ fun SessionScreen(
|
|||||||
toggle()
|
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.
|
* Whether the row holding transcript position [seq] is loaded, with older history behind it.
|
||||||
*
|
*
|
||||||
@@ -1486,6 +1508,15 @@ fun SessionScreen(
|
|||||||
) { unit ->
|
) { unit ->
|
||||||
when (unit) {
|
when (unit) {
|
||||||
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
|
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 ->
|
is TranscriptUnit.PeerHead ->
|
||||||
PeerHeadRow(
|
PeerHeadRow(
|
||||||
unit.item,
|
unit.item,
|
||||||
@@ -1576,6 +1607,12 @@ fun SessionScreen(
|
|||||||
::openImage,
|
::openImage,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
isWhole = { it in shownWholeCalls },
|
||||||
|
onShowAll = { capped ->
|
||||||
|
toggleAnchored(row) {
|
||||||
|
shownWholeCalls = shownWholeCalls + capped
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
is TranscriptRow.Single ->
|
is TranscriptRow.Single ->
|
||||||
when (val item = row.item) {
|
when (val item = row.item) {
|
||||||
@@ -1621,6 +1658,16 @@ fun SessionScreen(
|
|||||||
::openImage,
|
::openImage,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
isWhole = { part ->
|
||||||
|
Capped(item.id, part) in shownWholeCalls
|
||||||
|
},
|
||||||
|
onShowAll = { part ->
|
||||||
|
toggleAnchored(row) {
|
||||||
|
shownWholeCalls =
|
||||||
|
shownWholeCalls +
|
||||||
|
Capped(item.id, part)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
is TranscriptItem.QuestionCard ->
|
is TranscriptItem.QuestionCard ->
|
||||||
QuestionRow(item, ::answerAll)
|
QuestionRow(item, ::answerAll)
|
||||||
|
|||||||
@@ -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
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -9,6 +10,8 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
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.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.json.JSONObject
|
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
|
* 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.
|
* 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
|
@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) }
|
val parsed = remember(tool, input) { parseToolInput(tool, input) }
|
||||||
if (parsed.subject == null && parsed.rest.isEmpty()) return
|
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) {
|
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
|
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
|
||||||
// one being read closely.
|
// one being read closely.
|
||||||
Text(
|
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 --
|
// 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.
|
// 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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
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(
|
Text(
|
||||||
it,
|
shown,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CornerBasedShape
|
import androidx.compose.foundation.shape.CornerBasedShape
|
||||||
import androidx.compose.foundation.shape.CornerSize
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -163,6 +165,9 @@ fun ToolGroup(
|
|||||||
onToolToggle: (String) -> Unit,
|
onToolToggle: (String) -> Unit,
|
||||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||||
image: @Composable (String) -> 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"
|
val heading = "Called ${group.calls.size} tools"
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
@@ -202,6 +207,8 @@ fun ToolGroup(
|
|||||||
onToggle = { onToolToggle(call.id) },
|
onToggle = { onToolToggle(call.id) },
|
||||||
onAnswer = onAnswer,
|
onAnswer = onAnswer,
|
||||||
image = image,
|
image = image,
|
||||||
|
isWhole = { part -> isWhole(Capped(call.id, part)) },
|
||||||
|
onShowAll = { part -> onShowAll(Capped(call.id, part)) },
|
||||||
shape = connectedShape(index, group.calls.size),
|
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. */
|
/** Enough to read the join as a join rather than as one tall card. */
|
||||||
private val GROUP_GAP = 2.dp
|
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.
|
* One tool call.
|
||||||
*
|
*
|
||||||
@@ -291,6 +313,11 @@ fun ToolCard(
|
|||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||||
image: @Composable (String) -> 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]. */
|
/** Square where this card faces another in a group; see [connectedShape]. */
|
||||||
shape: Shape = CardDefaults.shape,
|
shape: Shape = CardDefaults.shape,
|
||||||
) {
|
) {
|
||||||
@@ -353,11 +380,28 @@ fun ToolCard(
|
|||||||
// something answerable; dumping the same JSON above them would be the decision
|
// something answerable; dumping the same JSON above them would be the decision
|
||||||
// stated twice, once unreadably.
|
// stated twice, once unreadably.
|
||||||
if (tool.tool != ASK_USER_QUESTION) {
|
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()) {
|
if (tool.output.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("Output", style = MaterialTheme.typography.labelSmall)
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// the text, so a card that is open through a scroll parses once.
|
||||||
val palette = remember { ansiPalette() }
|
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)) {
|
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(
|
Text(
|
||||||
styled,
|
styled,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
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
|
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
|
||||||
* [TranscriptCache.session] all take, rather than each growing its own branch between a session 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.
|
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
|
||||||
*/
|
*/
|
||||||
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
|
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
|
||||||
|
|||||||
@@ -130,6 +130,26 @@ sealed class TranscriptUnit {
|
|||||||
get() = "u$seq:$ordinal"
|
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]. */
|
/** One memory note of a settled reply; see [MemoryNote]. */
|
||||||
data class Memory(
|
data class Memory(
|
||||||
override val seq: Long,
|
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
|
* 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.
|
* screen, which is what a reversed lazy list calls the start.
|
||||||
@@ -161,11 +211,14 @@ fun transcriptUnits(
|
|||||||
rows: List<TranscriptRow>,
|
rows: List<TranscriptRow>,
|
||||||
replies: ParsedReplies,
|
replies: ParsedReplies,
|
||||||
openNotes: Set<Long>,
|
openNotes: Set<Long>,
|
||||||
|
shownWhole: Set<Any> = emptySet(),
|
||||||
): List<TranscriptUnit> {
|
): List<TranscriptUnit> {
|
||||||
val started = System.nanoTime()
|
val started = System.nanoTime()
|
||||||
val units = ArrayList<TranscriptUnit>(rows.size)
|
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 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
|
val item = (row as? TranscriptRow.Single)?.item
|
||||||
if (item is TranscriptItem.PeerNote) {
|
if (item is TranscriptItem.PeerNote) {
|
||||||
val open = item.seq in openNotes
|
val open = item.seq in openNotes
|
||||||
@@ -240,6 +293,16 @@ fun transcriptUnits(
|
|||||||
} else {
|
} else {
|
||||||
units += TranscriptUnit.Whole(row, rowGap)
|
units += TranscriptUnit.Whole(row, rowGap)
|
||||||
}
|
}
|
||||||
|
if (hidden != null) {
|
||||||
|
units +=
|
||||||
|
TranscriptUnit.ShowAll(
|
||||||
|
row.startSeq,
|
||||||
|
units.size - rowStart,
|
||||||
|
row.key,
|
||||||
|
hidden,
|
||||||
|
BLOCK_SPACING,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
units.reverse()
|
units.reverse()
|
||||||
reportDuplicateKeys(units)
|
reportDuplicateKeys(units)
|
||||||
@@ -266,8 +329,15 @@ 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
|
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
|
||||||
* ready parses.
|
* ready parses.
|
||||||
*/
|
*/
|
||||||
fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> =
|
fun unwarmedReplies(
|
||||||
rows.mapIndexedNotNull { index, row ->
|
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
|
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
|
||||||
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
|
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
|
||||||
}
|
}
|
||||||
@@ -375,6 +445,7 @@ private val TranscriptUnit?.kind: String
|
|||||||
is TranscriptUnit.PeerBlock -> "peer block"
|
is TranscriptUnit.PeerBlock -> "peer block"
|
||||||
is TranscriptUnit.UserChunk -> "user slice"
|
is TranscriptUnit.UserChunk -> "user slice"
|
||||||
is TranscriptUnit.Memory -> "memory note"
|
is TranscriptUnit.Memory -> "memory note"
|
||||||
|
is TranscriptUnit.ShowAll -> "show all"
|
||||||
is TranscriptUnit.Whole ->
|
is TranscriptUnit.Whole ->
|
||||||
when (val row = row) {
|
when (val row = row) {
|
||||||
is TranscriptRow.Tools -> "tool group"
|
is TranscriptRow.Tools -> "tool group"
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,17 +12,37 @@ script and README, because the Compose `bench` build type points its own asset s
|
|||||||
at `assets/` (`app/androidApp/build.gradle.kts`'s `sourceSets { getByName("bench") }`), and a Python
|
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:
|
script and a markdown file have no business inside an APK:
|
||||||
|
|
||||||
- `transcript.jsonl` -- 3,601 events. The first 3,200 (`BACKLOG_COUNT`) are the scrolled-back
|
- `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,
|
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
|
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
|
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
|
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
|
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."
|
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
|
- `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
|
same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two
|
||||||
`"type":"image"` events in the transcript.
|
`"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
|
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
|
`./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.
|
both apps' bench builds embed the identical bytes without needing this script at build time.
|
||||||
+3601
-3599
File diff suppressed because it is too large.
Load diff
@@ -26,8 +26,40 @@ import zlib
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SEED = 20260905
|
SEED = 20260905
|
||||||
BACKLOG_COUNT = 3200
|
# The stress message below, two events, added to the 3,200 the backlog used to be. Both apps
|
||||||
|
# hardcode this to split the file by line index (`fixture.rs`'s `BACKLOG_COUNT`,
|
||||||
|
# `BenchFixture.kt`'s), so it moves in three places or none.
|
||||||
|
STRESS_EVENTS = 2
|
||||||
|
BACKLOG_COUNT = 3200 + STRESS_EVENTS
|
||||||
STREAM_COUNT = 400
|
STREAM_COUNT = 400
|
||||||
|
|
||||||
|
# How many deltas fold into one markdown block of the streamed reply.
|
||||||
|
#
|
||||||
|
# **The blank line between them is the point** (2026-09-09). Until this run the streaming tail
|
||||||
|
# appended `paragraph(5) + " "` STREAM_COUNT times with no blank line anywhere, so all 400 deltas
|
||||||
|
# folded into a *single* 14,888-character block -- and a row re-shapes the block a delta lands in
|
||||||
|
# (`RowBlocks::apply_delta`), so every delta re-shaped the whole thing. That is quadratic in the
|
||||||
|
# reply's length, and it put 9.5ms of frame time on Iris's phone into a shape that does not occur:
|
||||||
|
# measured over 7,706 top-level blocks from 3,675 real assistant messages, block length is p50 147
|
||||||
|
# characters, p90 449, p99 836, largest 1,580, nothing above 4,000. `paragraph(5)` is ~35
|
||||||
|
# characters, so 4-12 of them per block lands in that range.
|
||||||
|
#
|
||||||
|
# The run-on version is kept, as STRESS_CHARS below -- Iris, 2026-09-09: "let's switch to new
|
||||||
|
# lines for the test, and also let's keep the single line around for stress".
|
||||||
|
DELTAS_PER_BLOCK = (4, 12)
|
||||||
|
|
||||||
|
# One deliberately pathological message in the backlog: a single markdown block with no blank line
|
||||||
|
# in it, the shape the streaming tail used to have. Sized just under `text_cap`'s MESSAGE_BYTES
|
||||||
|
# (16 KiB) so it is drawn in full rather than behind a "Show all" -- which makes it a genuine
|
||||||
|
# stress for one-shot shaping and puts a row right on the cap boundary, where nothing else is.
|
||||||
|
#
|
||||||
|
# It is *not* streamed: the repeated-reshape pathology needs a growing block, and that lives in
|
||||||
|
# `app-rust/tests/frame_profile.rs`'s `what_reshaping_a_growing_message_costs`, where it can be
|
||||||
|
# iterated on in a second rather than in a two-minute phone run. Note that the cap would not save
|
||||||
|
# a real one anyway -- `row::build_row`'s `cap` is deliberately `false` for the live tail, because
|
||||||
|
# a row that grew while capped would appear to stop growing, so a streamed block's shaping cost
|
||||||
|
# has no ceiling.
|
||||||
|
STRESS_CHARS = 14_800
|
||||||
HERE = Path(__file__).resolve().parent / "assets"
|
HERE = Path(__file__).resolve().parent / "assets"
|
||||||
|
|
||||||
random.seed(SEED)
|
random.seed(SEED)
|
||||||
@@ -122,6 +154,26 @@ def main():
|
|||||||
emit("status", state="running")
|
emit("status", state="running")
|
||||||
emit("settings", model="bench-model", permissionMode="auto")
|
emit("settings", model="bench-model", permissionMode="auto")
|
||||||
|
|
||||||
|
# The stress message, emitted first and with the random state put back afterwards, so that
|
||||||
|
# adding it is **purely additive**: every turn the loop below generates is byte-identical to
|
||||||
|
# what it generated before this existed, and only the streaming tail changed. That matters
|
||||||
|
# because the fixture's opening view is what `phone_screen.rs`'s recorded gestures press --
|
||||||
|
# `a_long_press_and_drag_selects_text` drives a real recording at (300, 1000) and fails the
|
||||||
|
# moment different content lands under it. `ts` still shifts by the two draws, which nothing
|
||||||
|
# reads.
|
||||||
|
rng_state = random.getstate()
|
||||||
|
emit(
|
||||||
|
"userMessage",
|
||||||
|
text="And the pathological one: a reply with no paragraph break in it.",
|
||||||
|
id=None,
|
||||||
|
attachments=[],
|
||||||
|
)
|
||||||
|
run_on = ""
|
||||||
|
while len(run_on) < STRESS_CHARS:
|
||||||
|
run_on += paragraph(5) + " "
|
||||||
|
emit("assistantText", delta=run_on.rstrip())
|
||||||
|
random.setstate(rng_state)
|
||||||
|
|
||||||
image_refs = []
|
image_refs = []
|
||||||
turn = 0
|
turn = 0
|
||||||
while seq <= BACKLOG_COUNT:
|
while seq <= BACKLOG_COUNT:
|
||||||
@@ -167,10 +219,20 @@ def main():
|
|||||||
trigger="auto",
|
trigger="auto",
|
||||||
)
|
)
|
||||||
|
|
||||||
# The streaming-phase tail: one long reply, built entirely from text deltas, the shape a
|
# The streaming-phase tail: one reply built entirely from text deltas, the shape a bench
|
||||||
# bench harness replays at a fixed events/sec through the live fold path.
|
# harness replays at a fixed events/sec through the live fold path -- with a blank line every
|
||||||
|
# few deltas, so the block a delta lands in stays the size a real reply's blocks are. See
|
||||||
|
# DELTAS_PER_BLOCK for what the alternative measured.
|
||||||
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
|
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
|
||||||
|
until_break = random.randint(*DELTAS_PER_BLOCK)
|
||||||
while seq <= BACKLOG_COUNT + STREAM_COUNT:
|
while seq <= BACKLOG_COUNT + STREAM_COUNT:
|
||||||
|
until_break -= 1
|
||||||
|
if until_break <= 0:
|
||||||
|
# The break rides on the last delta of the block rather than being an event of its
|
||||||
|
# own, so STREAM_COUNT still counts deltas a reader sees text arrive from.
|
||||||
|
emit("assistantText", delta=paragraph(5) + "\n\n")
|
||||||
|
until_break = random.randint(*DELTAS_PER_BLOCK)
|
||||||
|
else:
|
||||||
emit("assistantText", delta=paragraph(5) + " ")
|
emit("assistantText", delta=paragraph(5) + " ")
|
||||||
emit("status", state="idle")
|
emit("status", state="idle")
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
plugins { alias(libs.plugins.androidApplication) }
|
plugins { alias(libs.plugins.androidApplication) }
|
||||||
|
|
||||||
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
|
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
|
||||||
// into Rust (`../../android-shell`). Deliberately its own module rather
|
// into Rust (`../../app-rust`, the `shell` feature). Deliberately its own module rather
|
||||||
// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines
|
// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines
|
||||||
// of working Compose UI this experiment does not touch, and the two can be
|
// of working Compose UI this experiment does not touch, and the two can be
|
||||||
// installed side by side on the same development device (see
|
// installed side by side on the same development device (see
|
||||||
// `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme
|
// `settings.SCHEME`'s doc in `app-rust/src/shell` for why the deep-link scheme
|
||||||
// and Keystore alias are not the production app's). No Compose plugin, no
|
// and Keystore alias are not the production app's). No Compose plugin, no
|
||||||
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain
|
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain
|
||||||
// Java, and the CA constant below is generated as Java too.
|
// Java, and the CA constant below is generated as Java too.
|
||||||
@@ -13,7 +13,7 @@ plugins { alias(libs.plugins.androidApplication) }
|
|||||||
// The CA this build pins is baked in the same way `androidApp`'s does --
|
// The CA this build pins is baked in the same way `androidApp`'s does --
|
||||||
// see that module's `build.gradle.kts` comment for the reasoning (the
|
// see that module's `build.gradle.kts` comment for the reasoning (the
|
||||||
// trust boundary follows the machine that builds, never a pasted copy).
|
// trust boundary follows the machine that builds, never a pasted copy).
|
||||||
// `PinnedCa.java`'s package must match `android-shell`'s
|
// `PinnedCa.java`'s package must match `app-rust/src/shell`'s
|
||||||
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
|
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
|
||||||
val pinnedCaPath: String =
|
val pinnedCaPath: String =
|
||||||
System.getenv("AI_APP_CA")
|
System.getenv("AI_APP_CA")
|
||||||
@@ -150,12 +150,12 @@ androidComponents {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// The Keystore-sealed enrollment (ServerStore/ServerSettings) --
|
// The Keystore-sealed enrollment (ServerStore/ServerSettings) --
|
||||||
// android-shell's settings.rs calls into this Kotlin class directly
|
// the shell bridge's settings.rs calls into this Kotlin class directly
|
||||||
// over JNI rather than re-sealing the token in Rust; see that file's
|
// over JNI rather than re-sealing the token in Rust; see that file's
|
||||||
// module doc.
|
// module doc.
|
||||||
implementation(project(":link"))
|
implementation(project(":link"))
|
||||||
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
|
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
|
||||||
// ServiceCompat -- android-shell's notify.rs calls these classes over
|
// ServiceCompat -- the shell bridge's notify.rs calls these classes over
|
||||||
// JNI so the pre-26 fallback behaviour (no channels) lives once, in
|
// JNI so the pre-26 fallback behaviour (no channels) lives once, in
|
||||||
// the library that already has it, rather than being re-derived as a
|
// the library that already has it, rather than being re-derived as a
|
||||||
// set of Build.VERSION.SDK_INT branches in Rust.
|
// set of Build.VERSION.SDK_INT branches in Rust.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import android.widget.Toast;
|
|||||||
*/
|
*/
|
||||||
public class MainActivity extends Activity {
|
public class MainActivity extends Activity {
|
||||||
static {
|
static {
|
||||||
System.loadLibrary("android_shell");
|
System.loadLibrary("ai_app");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import android.os.IBinder;
|
|||||||
*/
|
*/
|
||||||
public class NotificationService extends Service {
|
public class NotificationService extends Service {
|
||||||
static {
|
static {
|
||||||
System.loadLibrary("android_shell");
|
System.loadLibrary("ai_app");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+16
-1
@@ -404,12 +404,27 @@ done
|
|||||||
# Percent-encoded because the app URL-decodes the deep link's query: a
|
# Percent-encoded because the app URL-decodes the deep link's query: a
|
||||||
# token with '+' in it enrols as one with a space, and nothing reports it.
|
# token with '+' in it enrols as one with a space, and nothing reports it.
|
||||||
enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN")
|
enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN")
|
||||||
|
# The CA rides in the link (`wg_app_link::enroll::ca_param`: base64url of
|
||||||
|
# the DER, which needs no percent-encoding). The Compose app ignores it and
|
||||||
|
# pins the copy its APK was built with; the iris app has no baked copy at
|
||||||
|
# all -- it is cross-compiled and could be pointed at any machine -- so
|
||||||
|
# without this it enrols and then trusts nothing. Minted here rather than by
|
||||||
|
# `--enroll-link` because this token is the sandbox's own, carried across
|
||||||
|
# restarts so the emulator stays enrolled (see the top of this file).
|
||||||
|
ca=$(python3 - "$CERTS/ca.pem" <<'CA'
|
||||||
|
import base64, sys
|
||||||
|
pem = open(sys.argv[1]).read()
|
||||||
|
body = pem.split("-----BEGIN CERTIFICATE-----")[1].split("-----END CERTIFICATE-----")[0]
|
||||||
|
der = base64.b64decode("".join(body.split()))
|
||||||
|
print(base64.urlsafe_b64encode(der).decode().rstrip("="))
|
||||||
|
CA
|
||||||
|
)
|
||||||
cat <<INFO
|
cat <<INFO
|
||||||
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
||||||
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
||||||
|
|
||||||
enrol the emulator (once; it survives sandbox restarts):
|
enrol the emulator (once; it survives sandbox restarts):
|
||||||
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc'"
|
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc&ca=$ca'"
|
||||||
|
|
||||||
drive it:
|
drive it:
|
||||||
./ui-sandbox.sh spawn [title] an echo session; prints its id
|
./ui-sandbox.sh spawn [title] an echo session; prints its id
|
||||||
|
|||||||
Generated
-940
@@ -1,940 +0,0 @@
|
|||||||
# This file is automatically @generated by Cargo.
|
|
||||||
# It is not intended for manual editing.
|
|
||||||
version = 4
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "adler2"
|
|
||||||
version = "2.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.23.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitflags"
|
|
||||||
version = "2.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bytes"
|
|
||||||
version = "1.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cc"
|
|
||||||
version = "1.4.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
|
|
||||||
dependencies = [
|
|
||||||
"find-msvc-tools",
|
|
||||||
"shlex",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfg-if"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "client-core"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"event-model",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tempfile",
|
|
||||||
"ureq",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cookie"
|
|
||||||
version = "0.18.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
|
||||||
dependencies = [
|
|
||||||
"percent-encoding",
|
|
||||||
"time",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cookie_store"
|
|
||||||
version = "0.22.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
|
||||||
dependencies = [
|
|
||||||
"cookie",
|
|
||||||
"document-features",
|
|
||||||
"idna",
|
|
||||||
"indexmap",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"time",
|
|
||||||
"url",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crc32fast"
|
|
||||||
version = "1.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "deranged"
|
|
||||||
version = "0.5.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "displaydoc"
|
|
||||||
version = "0.2.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "document-features"
|
|
||||||
version = "0.2.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
|
||||||
dependencies = [
|
|
||||||
"litrs",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "equivalent"
|
|
||||||
version = "1.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "errno"
|
|
||||||
version = "0.3.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "event-model"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastrand"
|
|
||||||
version = "2.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "find-msvc-tools"
|
|
||||||
version = "0.1.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "flate2"
|
|
||||||
version = "1.1.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
|
||||||
dependencies = [
|
|
||||||
"crc32fast",
|
|
||||||
"miniz_oxide",
|
|
||||||
"zlib-rs",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "form_urlencoded"
|
|
||||||
version = "1.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
|
||||||
dependencies = [
|
|
||||||
"percent-encoding",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "getrandom"
|
|
||||||
version = "0.2.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"wasi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "getrandom"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"r-efi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.17.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "http"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"itoa",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httparse"
|
|
||||||
version = "1.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "icu_collections"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
|
||||||
dependencies = [
|
|
||||||
"displaydoc",
|
|
||||||
"potential_utf",
|
|
||||||
"utf8_iter",
|
|
||||||
"yoke",
|
|
||||||
"zerofrom",
|
|
||||||
"zerovec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "icu_locale_core"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
|
||||||
dependencies = [
|
|
||||||
"displaydoc",
|
|
||||||
"litemap",
|
|
||||||
"tinystr",
|
|
||||||
"writeable",
|
|
||||||
"zerovec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "icu_normalizer"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
|
||||||
dependencies = [
|
|
||||||
"icu_collections",
|
|
||||||
"icu_normalizer_data",
|
|
||||||
"icu_properties",
|
|
||||||
"icu_provider",
|
|
||||||
"smallvec",
|
|
||||||
"zerovec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "icu_normalizer_data"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "icu_properties"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
|
||||||
dependencies = [
|
|
||||||
"displaydoc",
|
|
||||||
"icu_collections",
|
|
||||||
"icu_locale_core",
|
|
||||||
"icu_properties_data",
|
|
||||||
"icu_provider",
|
|
||||||
"zerotrie",
|
|
||||||
"zerovec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "icu_properties_data"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "icu_provider"
|
|
||||||
version = "2.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
|
||||||
dependencies = [
|
|
||||||
"displaydoc",
|
|
||||||
"icu_locale_core",
|
|
||||||
"writeable",
|
|
||||||
"yoke",
|
|
||||||
"zerofrom",
|
|
||||||
"zerotrie",
|
|
||||||
"zerovec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
|
||||||
dependencies = [
|
|
||||||
"idna_adapter",
|
|
||||||
"smallvec",
|
|
||||||
"utf8_iter",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna_adapter"
|
|
||||||
version = "1.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
|
||||||
dependencies = [
|
|
||||||
"icu_normalizer",
|
|
||||||
"icu_properties",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "indexmap"
|
|
||||||
version = "2.14.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
|
||||||
dependencies = [
|
|
||||||
"equivalent",
|
|
||||||
"hashbrown",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "itoa"
|
|
||||||
version = "1.0.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libc"
|
|
||||||
version = "0.2.189"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linux-raw-sys"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "litemap"
|
|
||||||
version = "0.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "litrs"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "log"
|
|
||||||
version = "0.4.34"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "memchr"
|
|
||||||
version = "2.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "miniz_oxide"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
|
||||||
dependencies = [
|
|
||||||
"adler2",
|
|
||||||
"simd-adler32",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "num-conv"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "once_cell"
|
|
||||||
version = "1.21.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "percent-encoding"
|
|
||||||
version = "2.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "potential_utf"
|
|
||||||
version = "0.1.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
|
||||||
dependencies = [
|
|
||||||
"zerovec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "powerfmt"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proc-macro2"
|
|
||||||
version = "1.0.107"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
|
||||||
dependencies = [
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quote"
|
|
||||||
version = "1.0.47"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "r-efi"
|
|
||||||
version = "6.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ring"
|
|
||||||
version = "0.17.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"cfg-if",
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"libc",
|
|
||||||
"untrusted",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustix"
|
|
||||||
version = "1.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"errno",
|
|
||||||
"libc",
|
|
||||||
"linux-raw-sys",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls"
|
|
||||||
version = "0.23.43"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"once_cell",
|
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"rustls-webpki",
|
|
||||||
"subtle",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-pki-types"
|
|
||||||
version = "1.15.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
|
||||||
dependencies = [
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-webpki"
|
|
||||||
version = "0.103.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
|
||||||
dependencies = [
|
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"untrusted",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde"
|
|
||||||
version = "1.0.229"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
|
||||||
dependencies = [
|
|
||||||
"serde_core",
|
|
||||||
"serde_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_core"
|
|
||||||
version = "1.0.229"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
|
||||||
dependencies = [
|
|
||||||
"serde_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_derive"
|
|
||||||
version = "1.0.229"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_json"
|
|
||||||
version = "1.0.151"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
|
||||||
dependencies = [
|
|
||||||
"itoa",
|
|
||||||
"memchr",
|
|
||||||
"serde",
|
|
||||||
"serde_core",
|
|
||||||
"zmij",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "shlex"
|
|
||||||
version = "2.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "simd-adler32"
|
|
||||||
version = "0.3.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "smallvec"
|
|
||||||
version = "1.16.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "stable_deref_trait"
|
|
||||||
version = "1.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "subtle"
|
|
||||||
version = "2.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "syn"
|
|
||||||
version = "2.0.119"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "syn"
|
|
||||||
version = "3.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "synstructure"
|
|
||||||
version = "0.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tempfile"
|
|
||||||
version = "3.27.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
|
||||||
dependencies = [
|
|
||||||
"fastrand",
|
|
||||||
"getrandom 0.4.3",
|
|
||||||
"once_cell",
|
|
||||||
"rustix",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "time"
|
|
||||||
version = "0.3.55"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
|
||||||
dependencies = [
|
|
||||||
"deranged",
|
|
||||||
"num-conv",
|
|
||||||
"powerfmt",
|
|
||||||
"serde_core",
|
|
||||||
"time-core",
|
|
||||||
"time-macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "time-core"
|
|
||||||
version = "0.1.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "time-macros"
|
|
||||||
version = "0.2.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
|
||||||
dependencies = [
|
|
||||||
"num-conv",
|
|
||||||
"time-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tinystr"
|
|
||||||
version = "0.8.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
|
||||||
dependencies = [
|
|
||||||
"displaydoc",
|
|
||||||
"zerovec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-ident"
|
|
||||||
version = "1.0.24"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "untrusted"
|
|
||||||
version = "0.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ureq"
|
|
||||||
version = "3.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"cookie_store",
|
|
||||||
"flate2",
|
|
||||||
"log",
|
|
||||||
"percent-encoding",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"ureq-proto",
|
|
||||||
"utf8-zero",
|
|
||||||
"webpki-roots",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ureq-proto"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"http",
|
|
||||||
"httparse",
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "url"
|
|
||||||
version = "2.5.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
|
||||||
dependencies = [
|
|
||||||
"form_urlencoded",
|
|
||||||
"idna",
|
|
||||||
"percent-encoding",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf8-zero"
|
|
||||||
version = "0.8.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf8_iter"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "version_check"
|
|
||||||
version = "0.9.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasi"
|
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webpki-roots"
|
|
||||||
version = "1.0.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
|
||||||
dependencies = [
|
|
||||||
"rustls-pki-types",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-link"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.52.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
|
||||||
dependencies = [
|
|
||||||
"windows-targets",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.61.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
|
||||||
dependencies = [
|
|
||||||
"windows-link",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-targets"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
|
||||||
dependencies = [
|
|
||||||
"windows_aarch64_gnullvm",
|
|
||||||
"windows_aarch64_msvc",
|
|
||||||
"windows_i686_gnu",
|
|
||||||
"windows_i686_gnullvm",
|
|
||||||
"windows_i686_msvc",
|
|
||||||
"windows_x86_64_gnu",
|
|
||||||
"windows_x86_64_gnullvm",
|
|
||||||
"windows_x86_64_msvc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_gnullvm"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_msvc"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnu"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnullvm"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_msvc"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnu"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnullvm"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_msvc"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "writeable"
|
|
||||||
version = "0.6.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "yoke"
|
|
||||||
version = "0.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
|
||||||
dependencies = [
|
|
||||||
"stable_deref_trait",
|
|
||||||
"yoke-derive",
|
|
||||||
"zerofrom",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "yoke-derive"
|
|
||||||
version = "0.8.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
"synstructure",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerofrom"
|
|
||||||
version = "0.1.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
|
||||||
dependencies = [
|
|
||||||
"zerofrom-derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerofrom-derive"
|
|
||||||
version = "0.1.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
"synstructure",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zeroize"
|
|
||||||
version = "1.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerotrie"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
|
||||||
dependencies = [
|
|
||||||
"displaydoc",
|
|
||||||
"yoke",
|
|
||||||
"zerofrom",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerovec"
|
|
||||||
version = "0.11.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
|
||||||
dependencies = [
|
|
||||||
"yoke",
|
|
||||||
"zerofrom",
|
|
||||||
"zerovec-derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerovec-derive"
|
|
||||||
version = "0.11.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zlib-rs"
|
|
||||||
version = "0.6.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zmij"
|
|
||||||
version = "1.0.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "client-core"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
# The app's pure logic, held once instead of twice: the event model (shared
|
|
||||||
# with `server/` via `event-model`), the REST + SSE clients for its HTTP
|
|
||||||
# surface (see `server/src/routes.rs`'s module doc for the table), the
|
|
||||||
# transcript fold and cache, the markdown block model, the syntax
|
|
||||||
# highlighter and the ANSI parser. See `docs/CLIENT_CORE.md` for
|
|
||||||
# what this holds today, what it does not yet, and how it corresponds to
|
|
||||||
# the Kotlin it replaces.
|
|
||||||
#
|
|
||||||
# No UI framework dependency of any kind -- this crate is meant to outlive
|
|
||||||
# whichever one the app ends up drawing with (see RUST.md).
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
event-model = { path = "../event-model" }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
# "raw_value" is `fetch_transcript_lines`'s reason -- it needs the exact
|
|
||||||
# bytes the server sent, not this crate's own re-serialization of a parsed
|
|
||||||
# `Value`, so a cached line and a live SSE frame for the same event agree
|
|
||||||
# byte-for-byte (see that method's doc). "float_roundtrip" is why they
|
|
||||||
# agree on a `ts` at all -- see server/Cargo.toml's identical comment.
|
|
||||||
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
|
||||||
# The blocking HTTP client for the REST calls and the long-lived SSE GETs.
|
|
||||||
# `server/` already depends on ureq for its own outbound HTTPS (the usage
|
|
||||||
# poll in usage.rs) and it is rustls-backed like the rest of this project's
|
|
||||||
# TLS, so this reuses that choice rather than pulling in reqwest's async
|
|
||||||
# stack -- a client that runs one blocking request at a time, the way
|
|
||||||
# Api.kt's `HttpURLConnection` calls and Sse.kt's blocking read loop do, has
|
|
||||||
# no need of an async runtime, and RUST.md's brief for this port is
|
|
||||||
# "lightweight" throughout.
|
|
||||||
ureq = { version = "3", features = ["json"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
//! What a Rust client needs to reach one enrolled server: host, port and
|
|
||||||
//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s
|
|
||||||
//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T`
|
|
||||||
//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and
|
|
||||||
//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from
|
|
||||||
//! the same text a phone would scan as a QR, with no second format
|
|
||||||
//! invented for it (RUST.md's E4).
|
|
||||||
//!
|
|
||||||
//! What this type deliberately does not decide: where it is persisted, and
|
|
||||||
//! under what file permissions. A phone seals its token in the Android
|
|
||||||
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/`
|
|
||||||
//! directory and its own file-mode conventions (MACHINE.md: owner-only,
|
|
||||||
//! never in the repo). Both are caller-specific, so they stay out of this
|
|
||||||
//! crate per the code rules' "ask for the least you need" -- see
|
|
||||||
//! `iris/desktop-app/src/config.rs` for the desktop instance.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
|
||||||
/// with `token` as a bearer header. Does not carry the pinned CA -- that is
|
|
||||||
/// a public certificate rather than a secret, and where to find it differs
|
|
||||||
/// by caller (a phone pins the one its APK was built against; a desktop
|
|
||||||
/// client is told a path).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct EnrolledServer {
|
|
||||||
pub host: String,
|
|
||||||
pub port: u16,
|
|
||||||
pub token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EnrolledServer {
|
|
||||||
/// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not
|
|
||||||
/// matter; unrecognised keys are ignored). `token` is percent-decoded,
|
|
||||||
/// since `ui-sandbox.sh` encodes it precisely because a raw token can
|
|
||||||
/// contain `+`, which turns into a space if left to a naive splitter.
|
|
||||||
pub fn parse_link(link: &str) -> Result<Self, String> {
|
|
||||||
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"'{link}' has no query string (expected \
|
|
||||||
aiapp://enroll?host=...&port=...&token=...)"
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut host = None;
|
|
||||||
let mut port = None;
|
|
||||||
let mut token = None;
|
|
||||||
for pair in query.split('&') {
|
|
||||||
let Some((key, value)) = pair.split_once('=') else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let value = percent_decode(value);
|
|
||||||
match key {
|
|
||||||
"host" => host = Some(value),
|
|
||||||
"port" => port = Some(value),
|
|
||||||
"token" => token = Some(value),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
|
|
||||||
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
|
|
||||||
let port: u16 = port_str
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
|
||||||
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
|
|
||||||
|
|
||||||
Ok(Self { host, port, token })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where a `client_core::api::UreqTransport` reaches this server.
|
|
||||||
pub fn base_url(&self) -> String {
|
|
||||||
format!("https://{}:{}", self.host, self.port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn percent_decode(s: &str) -> String {
|
|
||||||
let bytes = s.as_bytes();
|
|
||||||
let mut out = Vec::with_capacity(bytes.len());
|
|
||||||
let mut i = 0;
|
|
||||||
while i < bytes.len() {
|
|
||||||
if bytes[i] == b'%'
|
|
||||||
&& i + 2 < bytes.len()
|
|
||||||
&& let Ok(byte) =
|
|
||||||
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
|
|
||||||
{
|
|
||||||
out.push(byte);
|
|
||||||
i += 3;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push(bytes[i]);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
String::from_utf8_lossy(&out).into_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_host_port_and_token() {
|
|
||||||
let server =
|
|
||||||
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
server,
|
|
||||||
EnrolledServer {
|
|
||||||
host: "127.0.0.1".to_string(),
|
|
||||||
port: 8547,
|
|
||||||
token: "abcDEF123".to_string(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn field_order_does_not_matter() {
|
|
||||||
let server =
|
|
||||||
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(server.host, "example.com");
|
|
||||||
assert_eq!(server.port, 443);
|
|
||||||
assert_eq!(server.token, "tok");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_percent_encoded_token_is_decoded() {
|
|
||||||
// ui-sandbox.sh's own reason for encoding: a raw '+' would
|
|
||||||
// otherwise arrive as a space.
|
|
||||||
let server =
|
|
||||||
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
|
|
||||||
assert_eq!(server.token, "a+b/c");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_missing_field_is_named_in_the_error() {
|
|
||||||
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
|
|
||||||
assert!(
|
|
||||||
err.contains("token"),
|
|
||||||
"error should name the missing field: {err}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_non_numeric_port_is_named_in_the_error() {
|
|
||||||
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
|
|
||||||
assert!(
|
|
||||||
err.contains("port"),
|
|
||||||
"error should name the offending field: {err}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loaded 100 of 316 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user