Merge remote-tracking branch 'origin/rustify' into worktree-agent-a27094a7db775552a

# Conflicts:
#	AGENTS.md
#	server/src/session/driver.rs
This commit is contained in:
iris committed 2026-09-05 21:10:19 -04:00
commit 88631f5e8b
216 files changed
+52342 -461

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
# client-core
`client-core/` is the app's pure logic held once instead of twice, per
RUST.md's recommendation item 1. It is a plain Rust library crate with no UI
framework dependency of any kind, so it can outlive whichever one the app
ends up drawing with (Masonry, iris, or something else -- see RUST.md).
`event-model/` is its sibling: the wire shape both this crate and `server/`
share, extracted from `server/src/session/driver.rs` and
`session/transcript.rs` on 2026-09-04.
Neither crate is wired into anything yet. `server/` re-exports `event-model`
so its own behaviour is unchanged (`./run-tests.sh` covers it); `client-core`
has no caller -- it exists for whichever experiment in RUST.md picks it up
next (a Masonry or iris transcript screen, most likely).
## What's here, and what Kotlin file it replaces
| `client-core/src/…` | Kotlin original | Status |
|------------------------------------------|-------------------------------------------|--------|
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
| `api.rs` | `Api.kt` | Partial -- see below |
| `event_stream.rs` | `EventStream.kt` | Done |
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
| *(not started)* | `TranscriptSource.kt` | Not started |
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
cases ported alongside it, plus new tests for the pieces that had none
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
types carry no logic of their own to test -- `server/`'s own tests exercise
them via `session::transcript`'s round-trip coverage).
## Correspondence notes worth knowing before touching either side
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
non-overlapping spans. Whatever UI framework ends up consuming this
crate maps `Style` onto its own text-styling type; nothing here should
change to accommodate a particular one.
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
strings. `highlight::span_text` turns a `Span` back into text for a
caller working the same way; a caller that wants byte offsets into a
`&str` has to convert.
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
translation bug while it was being written: an early draft let a
*damaged* chunk (one file unreadable, discard just this session) and a
genuine I/O failure (disk gone, disable the whole cache) both surface as
the same `Err` from one closure, which would have disabled every
session's cache over a single corrupt chunk. Fixed by checking a
thread-local "was this damage" flag before deciding which failure mode
it was -- see the comment on `guard` and the commit message for
`transcript_cache.rs`.
## What `api.rs` covers, and what it does not yet
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
`ApiClient` and `event_stream::follow_session_events` are tested with a
fake transport and no server). `UreqTransport` is the only real
implementation, backed by `ureq` -- see its Cargo.toml comment for why
(blocking, already a project dependency, no extra TLS crate needed since
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
Covered: session list/read, message send, unqueue, answer, interrupt,
stop, start, rename, cwd, model, permission-mode, notify, command,
compact, delete, and one transcript page.
**Not covered, and each is real work rather than a stub to fill in:**
setups (`/setups*`, machine and provider discovery), the file explorer
(`/setups/{id}/dir|file`), usage (`/usage`), models
(`/models*`, HuggingFace browsing and downloads), attachments
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
the full table to work from when one of these is next.
## What `transcript_fold.rs` covers, and what it does not yet
`fold_event` covers every `Event` variant server/ can produce today,
including tool-call/question/image attachment and peer-message placement.
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
merges a tool call split across two fetched pages and re-merges a run a
boundary cut through. This matters the moment paging backward through
history is exercised; it is deliberately left rather than rushed, since
it is exactly the kind of boundary logic this project's own "things that
have bitten" section warns reads fine and is wrong at the edges.
**Known gap, and a decision for whoever closes it:** `event_model::Event`
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
A server newer than this build that adds an event type will fail to parse
that line rather than degrading to a placeholder row. Closing this means
deciding how `event_model` itself represents "a shape I don't recognise"
-- a shared-model decision affecting `server/` too, not a `client-core`-only
fix, so it is recorded here rather than silently worked around.
## `config.rs`: `EnrolledServer`
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
parses on the phone -- so any Rust client enrols from the same text a
phone would scan as a QR, with no second format invented for it (RUST.md's
E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is
persisted or under what file permissions -- a phone seals its token in the
Android Keystore, `iris/desktop-app/src/config.rs` writes it to
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
caller-specific (the code rules' "ask for the least you need"). Its only
caller today is `desktop-app`; a future Android build of this crate would
be a second one, not a reason to move the type.
## What is not started at all
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
from the transcript cache or the server, and stitches the two. Needs
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
which exist now, so this is unblocked whenever picked up.
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
colours a `.md` file or fence for the highlighter, but does not build the
block tree (headings, lists, tables, fences as distinct nodes) that a
renderer walks to lay out prose versus code versus a table.
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
AST is Compose rendering plumbing, not something to port as-is; a Rust
UI layer will want its own block parser or a crate for it, decided
alongside the framework choice in RUST.md.
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
it flattens a row into bounded units for a *specific* lazy-list
framework's composition cost, which is a fact about that framework
rather than about the transcript.
## Verifying
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
and `server` in that order (each `cargo test`, forwarding arguments the
same way it always has). From `client-core/` directly: `cargo test`,
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
+226
View File
@@ -0,0 +1,226 @@
# Decisions taken for Iris to review
Short list of design choices made by the design agent without asking, so
they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone.
## 2026-09-05
- **P0's Compose half is built and smoke-tested on the emulator** — the
`bench` build type, the shared `app/bench-fixture/` transcript, and an
in-process fake backend (`BenchFixture.kt`/`BenchNetwork.kt`) that
answers `TranscriptSource`/`EventStream` from an in-memory event log
instead of a real server, so the fold and paging under test are the real
ones. Full account, the smoke run's report, and what is deliberately
left (the iris half, the real on-phone runs) are in RUST.md's P0 box.
Not a decision to review so much as the gate itself now being runnable —
flagged here because it is the first half of something Iris explicitly
asked to see before P1.
- **The intermittent touch-scroll dropout is root-caused and fixed: a
missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced
first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every
touch event, `DragArbiter` state transition and `Selection::drag`
dispatch (removed once confirmed), reproduced on this checkout's own
emulator against a real sandbox session. The trace showed the actual
mechanism: a gesture's `ACTION_DOWN` lands wherever the finger actually
is, which is not guaranteed to fall inside the same row-local sensor
region a later `ACTION_MOVE` in the same gesture lands in (a row's own
padding/gap, or its non-selectable sender-name header, is
pointer-transparent to `iris::sense::CursorSense`). When that happens,
the widget that ends up handling the gesture never saw `PressStart`, so
`DragArbiter` sits in `Idle` — which answers every subsequent frame with
`Undecided` and has no way to tell "no press is happening" from "a press
is happening but I missed its start," so it never recovers on its own
for the rest of that gesture. One real trace showed exactly this: touch
`Down`/`Move`/`Up` all delivered correctly, but zero `PressStart`
reaching the arbiter, `state=Idle` unchanged from first frame to last.
Fixed at the call site that has the context to recover
(`iris::transcript_ui::selection::Selection::drag`,
`iris/transcript-ui/src/selection.rs`): a new `DragArbiter::is_idle()`
(`iris/src/sense.rs`) lets it notice a `Pressing` frame arriving with the
arbiter still `Idle` — which can only mean a missed `PressStart`, since a
`Pressing` sense requires the button to genuinely be down — and start the
press there instead of where it was missed. Three new unit tests in
`sense.rs`'s `drag_arbiter_tests` and one in `transcript-ui`'s
`selection::tests` (the latter fails on the code before this fix).
Commit follows. Not the same failure the earlier pass's `DECISIONS.md`
DEFERRED item speculated about (a coalesced first `ACTION_MOVE` skipping
slop detection) — that hypothesis is now ruled out; the arbiter's own
slop/long-press logic was never wrong. RUST.md's I5 box,
"Touch-scroll dropout root-caused, 2026-09-05" has the full trace.
- **P0, a phone benchmark gate before any porting, asked for by Iris
2026-09-05**: "before P1 I'd like to see benchmarks & also maybe stress
test on my own phone ... If it doesn't match compose reasonably well then
I don't think I'd wanna continue." Design (RUST.md's P0 box has the
detail): the same embedded synthetic fixture in both apps with no server
needed; the same scripted scroll loop then a streaming phase, run
programmatically since the phone has no usable system tracing and no
agent can drive it; the same report from both (frames, janky %, p50/p90/
p99, process CPU time, peak RSS, battery current where readable) with a
copy button; the iris app under its own id and the Compose one as a new
`bench` build type with an id suffix, so neither replaces her production
install; two arm64 APKs plus instructions delivered under `~/host/bench/`.
The gate is hers: iris within a reasonable margin of Compose release on
p50, p99 and CPU time, no crashes, no visible stutter. If it fails, the
port stops.
- **The rest of the port is one UI crate, `iris/app-ui`, grown out of
`iris/transcript-ui` rather than started beside it.** It holds a
`Screen` enum plus a back stack — the Rust equivalent of `AppRoot.kt`'s
`when` — and `iris/desktop-app`/`iris/android-app` become thin entry
points over it. Chosen over a fresh crate because `transcript-ui`
already has the right generic shape (`Rsc: HasEvents` +
`Rsc::State: FocusHost`) and the `client-core`/`event-model` path
dependencies every later screen needs, so growing it in place is the
smaller diff. Platform-only code (notification service, share target,
QR scanner, Keystore token, deep-link enrolment) stays in the E3/E5
Java shell (`android-shell/` + `app/shellApp`) rather than moving into
this crate, since none of it is a screen. The Android APK is built by
`cargo xtask apk` (E5), merging the app-ui cdylib into the E3 shell so
there is one app rather than a demo shell plus a service shell.
`app/androidApp` (the Compose app) stays untouched and is the baseline
every step is measured against, until parity is reached (P7 decides
the switch, and is itself a load-bearing decision left to Iris). Order
is by risk to the daily-use path: session screen first (P1, where
every hard behaviour already lives), then the shell merge and a real
phone install (P2), then root tabs (P3), the explorer (P4),
settings/enrolment (P5), desktop parity (P6), and the cutover itself
(P7). Full plan: RUST.md's "The port, in order (decided 2026-09-05)".
- **iris gets its own measured frame report, rather than waiting on a
`dumpsys`/`gfxinfo` answer that cannot see a `SurfaceView`'s GPU-drawn
frames.** `iris_core::FrameReport` (`iris/core/src/render/frame_report.rs`)
times each frame's wall clock from the same point `render()`'s redraw
starts to just after `queue.submit` + `present()` — the span Compose's
own render report and `gfxinfo` both count — into a fixed 4096-entry
ring (no allocation per frame; `report()` is the only place that
allocates, and only on a button tap). The report gives total frames,
janky % over the same 16.7ms budget `gfxinfo` uses, P50/P90/P99 and the
worst, plus a reset. Exposed the way the Compose app's copy-button
report already is: two named controls ("Frame report", "Reset frame
report") on the transcript screen, tappable by accessibility name via
`ui-trace`, logging under this crate's fixed `android_logger` tag
(`iris-android-app`) so a script can grep `"iris frame report"` the way
`transcript-bench.sh` greps `"ai-app render report"`. The report's own
`Display` line says plainly that it measures up to the `present()` call
returning, not GPU/compositor completion — wgpu's `present()` is not
fenced against either, so presenting that span as "time to reach the
screen" would be a measured-looking number that is actually inferred,
which the standing UI rule forbids.
- **`ui-trace` gains a hold-then-drag gesture, additive, in
`emulator-tools`.** Neither of its two existing actions can produce
"hold stationary for `LONG_PRESS`, then move without lifting" — `tap`
has no hold and `swipe X1 Y1 X2 Y2 MS` interpolates motion across its
whole duration from t=0. A new action presses, waits, then moves to a
second point and releases as one continuous touch (raw
`sendevent`/`MotionEvent` injection, extending whatever mechanism the
existing `swipe` already uses), so `DragArbiter`'s pan-vs-select rule
(`iris/src/sense.rs`, already covered by 8 unit tests against a
synthetic clock) can finally be driven on a real device instead of only
in a test harness.
- **Touch drag on a transcript row follows Android's own rule**: a vertical
drag pans the list immediately; a stationary press held 500 ms starts a
text selection which further dragging extends; a horizontal drag while
something is already selected extends that selection without the wait.
One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a
"text layer always wins" or "list always wins" rule because either loses
one of the two gestures a reader expects.
- **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window
holding `transcript-ui`'s screen beside a session list, talking to a real
`ai-server` through `client-core`. It enrols by pasting the same
`aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`)
and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The
pinned CA is a path given on the command line, not baked in. Chosen so
the phone and desktop share one enrolment format and no second one is
invented.
- **I5's Android integration extends `iris-android-app` (I2's shell)
behind a Cargo feature (`transcript-screen`), rather than a third
shell crate.** That project already has the Gradle module, the
`IrisView`/`MainActivity` Java, and the JNI registration; the only
thing a second screen needs on top is a different `AndroidAppState`,
the same axis `tabs_ui::build`/`transcript_ui::build` already vary
along on the winit side. `tabs-screen`/`transcript-screen` are
mutually exclusive and each pulls in only its own deps, so the plain
tabs build (I2/I4) is untouched.
- **Order of remaining work, updated 2026-09-05**: the two in-flight
pieces and I5's Android integration are all done; next is giving iris
its own frame-timing report so item 3 below can be decided by a number.
- **DECIDED by Iris, 2026-09-05: iris is the app's framework; Masonry was
the calibration.** Her words: "I think iris definitely makes more sense
based on the limitations we've found." The limitations: Masonry has no
touch scroll on Android (E2), no per-span rich text and no cross-row
selection on the pinned commit (E2), and its keyboard bridge is a TODO
(E1); iris carries the same screen under the Compose baseline on the
host GPU (p50 15.0 ms against Compose's 20.0 ms, RUST.md's I5 box). What
follows: the E-steps are closed as calibration, and the port proceeds
on iris — screens, the shell (E3/E5), and `client-core` underneath.
The item below is kept as the record of what she decided from.
- **Was DEFERRED — whether to commit to iris over Masonry for `ai-app`.**
Updated 2026-09-05 with the clean comparison the recommendation wanted:
same sandbox session content, same emulator, `EMU_GPU=software`, one
session. Headline numbers (RUST.md's I5 box, "Clean scroll comparison,
2026-09-05," has the full table and every caveat):
| app | build | frames | janky % | p50 | p90 | p99 | worst |
|---|---|---|---|---|---|---|---|
| Compose (in-app report) | debug | 1102 | 99.0% late | 33.8ms | 50.6ms | 79.5ms | -- |
| Compose (`dumpsys gfxinfo`) | debug | 1499 | 21.15% (95.66% legacy) | 32ms | 48ms | 150ms (p99) | -- |
| iris (`FrameReport`) | **release** | 299 | 94.65% | 79.1ms | 98.6ms | 117.8ms | 212.6ms |
| iris (`FrameReport`, repeat) | **release** | 233 | 94.42% | 109.3ms | 130.8ms | 147.1ms | 150.5ms |
**Not a clean apples-to-apples reading, stated plainly rather than
smoothed over**: iris had to be built **release** (debug `SIGSEGV`s on
this emulator's Vulkan loader, I4's finding) against Compose's mandated
**debug** build, so this asymmetry likely *understates* iris's gap
rather than the reverse; the three frame-time sources measure different
things (Compose's own phase accounting vs. Android's HWUI deadline-miss
definition vs. iris's redraw-start-to-present window, the last of which
`dumpsys gfxinfo` cannot see at all for iris's `SurfaceView`); and both
figures are emulator numbers under software rasterisation, which
Compose's *own* in-app report shows already costs 20-34ms/frame in
`swap`+`gpu` alone under this GPU mode, so a same-mode iris number well
above 16.7ms was expected going in for either app. A second pair under
`-gpu host` was not taken this pass. The earlier session's suspected
intermittent touch-delivery dropout was **not reproduced** this pass —
the zero-frame results this time traced to this pass's own script bug
(a `cd` that changed which emulator `ui-trace` targeted), not the
emulator; a CPU-load rise during the gesture was observed by a sampler
running throughout, but did not correlate with any failure, so the
original candidate is neither confirmed nor ruled out.
The choice in front of Iris, updated: decide now on the
structural-plus-functional case already made (iris works end-to-end
where Masonry's scroll gesture doesn't exist at all on Android) plus
this table — reading the two build profiles and three jank definitions
with the caveats above rather than as a single number — or ask for a
same-profile, same-GPU-mode rerun first. RUST.md's I5 box has the full
account.
**Updated 2026-09-05, the `-gpu host` pair taken.** Real GPU rendering
(`force-gles` -- the default Vulkan backend has no adapter at all under
plain host-GPU boot, confirmed by the exact `wgpu` error) reverses the
software-mode shape:
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|---|---|---|---|---|---|---|---|---|---|---|
| Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- |
| iris (`FrameReport`) | **release**, `force-gles` | host (virgl) | 62 | 41.94% | 15.0ms | 21.8ms | 37.1ms | 37.1ms | 0.2ms | 12.9ms |
Under real GPU rendering iris's median frame is *faster* than
Compose's, not the 2-3x-slower shape the software-mode table shows. A
new split inside `FrameReport` (redraw-to-submit vs. submit-to-present,
commit `e2a1fad`) says why: iris's own CPU work per frame is a median
0.2ms -- almost the entire frame is time spent handing the frame to the
driver, not in iris's layout/text/primitive code. This is consistent
with the earlier software-mode gap being mostly SwiftShader's CPU
rasterisation cost rather than an iris-specific slowness, but is not
proof of it: a same-mode software `force-gles` run to isolate the
backend crashed for an unrelated reason (SwiftShader's GL path reports
itself as OpenGL ES 3.0, which has no compute shaders, and iris's device
request assumes them unconditionally) — real scope to fix, not done
here — and the two apps' frame populations still differ in kind the same
way the software-mode caveats describe. A real intermittent touch-
scroll dropout was also reproduced this pass (six consecutive swipes
produced zero redraws while taps kept working; an identical retry then
succeeded) and is not explained. RUST.md's I5 box, "Where iris's frame
time goes, 2026-09-05, the `-gpu host` pass," has the full account. The
iris-vs-Masonry choice itself is still Iris's to make.
+351
View File
@@ -0,0 +1,351 @@
# The file explorer
Asked for by Bryan on 2026-09-03 and built the same day: browse a machine's
directories, open files with the existing syntax highlighting and line
numbers, edit behind a pencil, create through a modal, work over ssh, and
open at the session's working directory.
This is the design, decision by decision with the reason and what was
rejected, so that when one changes it is changed here rather than re-argued.
The operational half — how to run it and what to produce on purpose — is in
AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
`FileViewer.kt` / `FileEditor.kt` / `FileLines.kt` are the app.
## What it is, in one paragraph
A machine's filesystem, seen from the phone through the backend. The explorer
belongs to a **setup** (a machine), not to a session: a session only says
where to start. Every operation — list, read, write, create — is one shell
script run through `Transport`, exactly the way the import listing and the
usage fetch already work, so the local and the ssh case are one
implementation and a machine the backend cannot reach fails with ssh's own
message. The phone draws what came back.
## Decisions
### 1. Keyed on the machine, opened from the session
Routes live under `/setups/{id}/…`, beside `importable`, because a filesystem
is a property of a machine. The session screen's folder button opens the
explorer with the session's setup and its `cwd`; a session with no `cwd`
opens at the machine's home, which the **machine** resolves (`cd` with no
argument and `pwd -P`), never a path the phone guessed. Nothing in the
explorer knows what a session is, so a later entry point from the setups tab
is one more caller and no new code.
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
find the setup, and "browse this machine" from anywhere else would need a
session to exist first.
### 2. One shell script per operation, over `Transport`, on both transports
Each operation is a small POSIX script handed to `sh -c script sh "$path" …`
through `Transport::capture` (or `capture_with_input`). The path and every
other value cross as **positional arguments**, never interpolated into the
script — the same rule `import::find` follows and the same reason
`ssh::quote` exists: a path is attacker-adjacent input in a server whose job
is running commands. `PATH_PRELUDE` is the one line that gives a leading `~`
its meaning, since a shell expands a tilde in text and not in an argument.
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
`sha256sum`, `chmod --reference`) — already what `import.rs` assumes, and
both machines that exist are Linux. A machine without them fails with that
tool's own message, which names what is missing.
Rejected: `std::fs` for the local transport and scripts for ssh. Two
implementations of "list a directory" drift — the ordering of entries, what a
symlink reports, how a permission error reads — and the local one is the one
that gets tested, so the remote one ships broken. The cost is an `sh` process
per operation locally, which is under a millisecond.
Rejected: a Rust SSH or SFTP library. The system `ssh` inherits
`~/.ssh/config`, agents and jump hosts, and there is one place to configure a
connection; SFTP would need a second.
### 3. The token can now name a path, and that is written down
Elsewhere the phone picks an **id** and the server resolves which file it
names, so an enrolled token cannot become "read me an arbitrary file". The
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
Security section in these terms: the token already gates spawning a
bypass-permissions agent in any directory on any machine a setup names, and
that agent can already read and write every file its user can. The explorer
is a shorter path to authority the token already holds, not new authority.
The import rule stands where it is, because there a path was unnecessary and
refusing it cost nothing.
What is *not* changed: **no route accepts a command.** Listing, reading and
writing are fixed scripts; the phone chooses only the path and the bytes.
### 4. Paths are absolute or `~`-prefixed, and the machine answers with the real one
Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where
a relative path would be depends on something the reader cannot see. Every
listing answers with `pwd -P` of the directory it listed, so the phone
navigates on a resolved absolute path — the parent is a string operation on
that, and a `~` the session was spawned with is shown as what it turned out
to be. The phone never resolves `..` itself.
### 5. A read is capped and typed, and every state it can be in has a word
`GET /setups/{id}/file` answers with one of `text` (content, size, mtime,
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
at), or the machine's own error.
Four outcomes rather than content-or-error, because a binary file drawn as
text and a big file cut off silently are both wrong in ways the reader cannot
see, and "couldn't read it" must not look like "it is empty". An empty file
is `text` with empty content, drawn as one empty line numbered 1, which is
what it is.
### 6. A write is conditional on what the reader saw
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
compares it against the file as it is now and exits distinctly if it differs;
the server answers **409**. Agents edit files while people read them; this is
the common case, not the exotic one, and silently overwriting an agent's edit
with a stale copy is the worst available outcome. The phone offers three ways
out and says what each costs: **Overwrite** (theirs is lost), **Reload**
(yours is lost), **Cancel** (keep editing).
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1" … && mv -f`,
with the bytes on stdin: a temp file and a rename, so a connection dropped
mid-write leaves the old file whole rather than truncated, and
`chmod --reference` keeps the mode a fresh file would lose (an executable
script would stop being one). What this trades away is the inode, so a hard
link elsewhere stops being the same file — accepted; editors do the same. The
check-then-write is not atomic against a writer landing between the two, a
window of microseconds on the same machine; accepted, and noted at the
script. The response carries the new size, mtime and sha256, so the editor's
precondition is fresh without a second read.
### 7. Create refuses to overwrite
`POST /setups/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a
name that exists fails with the shell's own message rather than truncating
somebody's file; `POST /setups/{id}/dir` is `mkdir --` with the same
property. The modal names one thing in the current directory and has a switch
for "directory"; a created file opens straight into edit mode, because an
empty file is not something to look at.
Rejected: create-with-content in one request. The editor is where content is
typed, and a modal with a text area is a second editor.
### 8. The viewer is a list of lines, coloured once
The file is scanned once, **off the main thread**, by `scan` in
`Highlighter.kt`; the spans are bucketed per line in one pass and each line's
`AnnotatedString` is built when that line is composed. A `LazyColumn` of
lines, not one `Text`: text layout is linear in the text, so a 20,000-line
file in one `Text` measures all of it to draw a screenful.
**Every row is given the same width**, and that is what makes the shared
horizontal scroll work. `horizontalScroll` is a node per row, and each one
coerces the shared offset into *its own* range — content width less viewport
— so with rows at their natural widths a short line's range is zero and it
does not move at all while the long line beside it does. Each row also writes
`maxValue` as it measures, so how far the file could be dragged was decided
by whichever row measured last and changed as the list scrolled. The width is
the longest line in columns times one character's advance, which is
arithmetic rather than twenty thousand measurements because the face is
monospace. A tab counts as eight columns and deliberately upwards —
over-estimating leaves a little empty space past the longest line,
under-estimating puts the end of that line out of reach — and the width is
capped well under what `Constraints` can carry, so a minified file is a
scroll that stops early rather than a crash. Reported by Iris on 2026-09-04
as "it seems to affect different rows differently", which is precisely what a
per-row range looks like.
**The stretch at the ends is one effect too**, shared by every row and
rendered once on the box around the list — `horizontalScroll` makes its own
per node otherwise, so only the line under the finger bent while the rest of
the file sat still. It cannot be seen from this VM: the emulator's
screenshots come back with no stretch in them at all, for any scrollable, so
that one is checked on the phone.
**The numbers sit outside that box**, so they neither travel with the text
nor bend with it. The rows leave a spacer where the numbers go and a
`SubcomposeLayout` beside the list draws them. That is the one arrangement
that keeps them level: which numbers exist *and* where each goes both come
from the list's own `layoutInfo`, read in the measure block, and
subcomposition happens during measurement — so it composes from the answer
the list has just produced rather than one it read a frame ago. A column
translated by the scroll position could not, since the translation would be
current while the set of numbers was a composition behind, and during a fling
the numbers would slide against their lines. Checked at about 1kHz through a
fling: 23,520 row observations over 552 frames, every one with its number at
exactly its own top. A consequence worth having: the numbers are outside the
`SelectionContainer`, so copying part of a file gives the code rather than
the code with a number in front of every line.
The gutter is right-aligned, its width taken from the digit count of the line
count in the same monospace style, so a 9-line file and a 12,000-line file
each get exactly the width they need and nothing is measured by hand. Because
nothing wraps, a logical line is one visual line and the gutter cannot drift
from the text it numbers. Numbers take `onSurfaceVariant`; the text takes the
scanner's palette on `rawSurface`, the surface every verbatim thing in the
app already sits on.
The language comes from the file's extension through the same table
`fenceLanguage` reads — one table, not two, so a language added for fences is
added for files. A file with no entry is drawn plain.
### 9. The editor is the legacy text field with a highlighting transformation
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the same
monospace style, inside the same horizontal scroll so it does not wrap, with
a `VisualTransformation` that returns the text unchanged and the scanner's
spans as styles (`OffsetMapping.Identity`, since no character moves). This is
the one Compose API that colours a field's text without replacing the field;
the newer `TextFieldState` API has no hook for styles. The gutter is one
`Text` of `1\n2\n…` beside the field, aligned for the same reason as the
viewer.
Save is a glyph in the header, **disabled** until the text differs from what
was loaded — never hidden, since a control that comes and goes makes its own
absence the signal. Back with unsaved changes asks, and says the edits will
be lost. The explorer draws over the session, which deliberately has no
`imePadding`, so the explorer's own box adds it.
### 10. The explorer draws over the session, and back closes it first
`Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
`FilesScreen` is composed **on top of** the session in the same `Box`, and
the session stays composed under it: its event stream keeps flowing, its
scroll position and draft stay where they were, and returning from a file
costs nothing. Back — the button and the platform gesture — clears `files`
when set and goes to the list otherwise. Inside the explorer the same back
steps one level: editor → viewer (with the unsaved question) → listing →
parent directory, and only from the starting directory does it close. "Back
returns; it does not exit."
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a
leaf screen goes to Main today, and a session disposed and re-created on each
return refetches its transcript over the tunnel — exactly the flip between
"what did it change" and "what is it saying" this feature is for. The image
viewer already made the same choice for the same reason.
### 11. The listing is drawn as it came, sorted at display time
Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and
whether the entry is a symlink — with the kind being the *target's*, from
`find -printf '%Y'`, so a link to a directory navigates. Sorted on the phone,
stably: directories first, then case-insensitive name. Dotfiles are shown; in
a repository they are half of what matters. Each directory's entries are kept
for as long as the explorer is open, keyed by path, so returning to one does
not refetch it; the header's refresh glyph refetches the current one on
purpose, and a create refetches the directory it created into, since that is
what the operation changed.
An empty directory says "Nothing here". A listing that failed says why, in
the machine's words, where the rows would be — never an empty list.
Entries are separated by `\0` in the script's output and by `\t` within a
line, so a filename with a newline or a tab in it survives; `parse_entries`
is a unit test with exactly those names in it.
### 12. Icons
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script rerun
and its output committed: `md-folder` U+F024B (the header button and
directory rows), `md-plus` U+F0415, `md-pencil` U+F03EB,
`md-content_save` U+F0193, `md-file_outline` U+F0224. The folder and the plus
are the same codepoints dev-updater uses and must not drift from it, as the
cog and the refresh arrow already must not. All five were looked up in Nerd
Fonts' own `glyphnames.json` rather than copied from memory, which is the
check that a codepoint means the glyph its comment names.
**The folder button sits between the usage chart and the cog**, so the header
reads widest scope to narrowest and the cog stays at the end where every
other screen keeps it. Asked for in that order by Iris on 2026-09-03.
### 13. The render report moved, and the benches moved with it
The speedometer went; the report is a "Copy render timings" row in
`SessionSettingsDialog`, where the session's other about-the-session controls
already are. **Moving it is where the no-coordinate-taps rule got enforced**
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
## HTTP surface
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
like every other body here; paths in the query string are URL-encoded by
`Api.kt`'s existing helper.
```json
GET dir -> {"path":"/home/bob/repos/ai-app",
"entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false}]}
GET file -> {"path":"/…/x.rs","kind":"text","size":1234,"modified":,"sha256":"…","content":"…"}
| {"path":"/…/a.png","kind":"binary","size":45678,"modified":}
| {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":}
PUT file -> {"size":1240,"modified":,"sha256":"…"}
```
Errors: `BadRequest` with the machine's message for a path that is not there,
not allowed or not absolute; 409 for the precondition; `Internal` only for
the server's own faults. The message is what the phone shows, in place, so it
is written to be read there.
## What the measurements said (2026-09-04)
Taken on the emulator in a **debug** build, which runs Compose at a fraction
of release speed and renders in software — so these rank correctly against
each other and are pessimistic in absolute terms. Generated Rust, through the
app's own render report.
| file | lines | scan + cut | scan per keystroke | worst frame record |
|--------|--------|------------|--------------------|--------------------|
| 32 kB | 917 | 11ms | 10ms | 183ms |
| 128 kB | 3,633 | -- | 40ms | 2,027ms |
| 1 MB | 28,660 | 460ms | -- | -- |
Three things followed.
**The viewer's scan had to leave the main thread.** Decision 8 said "off the
main thread" and the first version did it in a `remember` inside the
composition, which is not that: 460ms of frozen screen at the size the server
is willing to send, long enough that the accessibility tree cannot be read —
which is exactly what "the app has stopped" looks like from outside.
**`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a
1 MiB file, tap to text on screen, was **2.4s** against the sandbox — 1.2s of
which is that server's deliberate `--delay`, and 460ms the scan. The transfer
is not what dominates, so the route gains nothing from streaming.
**Edit mode needed a cap, and not the one that was expected.** The plan
expected to be deciding a size below which highlighting stays on. That is not
the cost that matters: highlighting 128 kB costs 40ms a keystroke, which is
survivable, while laying the same text out in one `BasicTextField` costs two
seconds — characters typed into it were dropped, and a 1 MiB file stopped the
app responding altogether. Since every arrangement of a single text field
pays that, switching highlighting off would have saved nothing. So
`EDIT_LIMIT` is **32 kB**, the largest size measured as usable, and above it
the pencil is disabled with the reason said in words beside it — a disabled
control teaches what the thing can do but cannot say why it is off, and a
reader who cannot edit a file they can plainly read would otherwise conclude
the app is broken.
Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine,
because it is a `LazyColumn` of lines rather than one text object. That
difference is the whole of decision 8.
## Later, deliberately not now
- Delete, rename and move. Destructive controls belong here eventually, shown
and confirmed rather than hidden, but none is needed to read or change a
file.
- Images in the viewer, through the existing `SessionImageViewer`.
- Following an agent's edits live: a file open in the viewer refreshing when
a `Write`/`Edit` tool call on the same path lands in the transcript. The
transcript already knows the path.
- Remembering the last directory per session.
- Uploading from the phone into a directory. Attachments already do the
upload half.
- Search within a file, and find-in-files.
- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The viewer
already draws a file as rows and stays fast on a megabyte; an editor built
the same way — a field per line, or a field over the lines on screen —
would not pay Compose's cost of laying out one enormous text. It is a good
deal more than this feature needed, and 32 kB covers the config files,
notes and ordinary source files anybody edits from a phone.
+391
View File
@@ -0,0 +1,391 @@
# iris: notable public API changes
For Iris to read on her own time. Each entry is a change to iris's public
surface that a widget author or app author would notice: a trait method
added, removed or re-shaped; a type that callers construct differently; a
capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-05 (later the same day): `iris_core::FrameReport` (RUST.md's I5 box)
New public type, `iris_core::FrameReport` (re-exported from `iris_core`'s
`render` module alongside `FrameStats` and `JANK_THRESHOLD`). Why: `dumpsys
gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all, so a
`wgpu`-rendered iris screen had no way to ask "was this smooth" the way
Compose's own in-app render report already can -- item 3 of RUST.md's
recommendation was stuck on a one-sided number for exactly this reason.
`FrameReport::record(elapsed: Duration)` is called once per frame (wired
into `android/view.rs`'s `render()`, wrapping the same span from redraw
start to after `queue.submit`+`present()` that Compose's report and
`gfxinfo` both count) and writes into a fixed 4096-entry ring -- no
allocation on the hot path. `FrameReport::report() -> Option<FrameStats>`
gives total frames, janky % (over `JANK_THRESHOLD`, the same 16.7ms 60Hz
budget `gfxinfo` uses), P50/P90/P99 and the worst; `None` if nothing has
been recorded since the last `reset()`, not a zeroed report that would
read as a real measurement. `FrameStats`'s `Display` line says plainly
that it measures up to `present()` being called, not GPU/compositor
completion, since wgpu's `present()` isn't fenced against either.
`AndroidUiState` gained a `pub frame_report: FrameReport` field --
anything with `HasAndroidUiState` can now read or reset it. Before this,
there was no way to ask iris's own render path how long a frame took at
all, on any backend.
Before/after, for a caller that already has `ui_state: &AndroidUiState`:
```rust
// before: no such question could be asked
// after:
match ui_state.frame_report.report() {
Some(stats) => log::info!("iris frame report: {stats}"),
None => log::info!("iris frame report: no frames recorded yet"),
}
ui_state.frame_report.reset(); // via android_state_mut()
```
`iris-android-app`'s transcript screen exposes this as two named,
tappable controls ("Frame report", "Reset frame report") rather than
requiring a caller to wire its own UI -- see `transcript_client.rs`'s
`frame_report_controls`.
## 2026-09-05: `Tasks::redraw_handle` (RUST.md's I5 Android integration)
New public method on `iris::task::Tasks`, `redraw_handle(&self) ->
Arc<dyn RequestRedraw>`. Why: a caller running its own long-lived loop
*inside* one spawned task (a live SSE follow, the Android transcript
client's `select_session`) has no other way to ask for a frame after each
`TaskCtx::update` -- `Tasks::spawn`'s own wrapper only requests one, after
the whole async closure finishes, which fits a single request-then-update
but not a stream that needs to be seen redrawing after *each* event. This
is the same gap `iris/desktop-app`'s module doc names for why it uses
winit's `Proxy<AppEvent>` instead of `Tasks` -- android-view has no
`Proxy`, so this is what closes it there.
**A real bug this uncovered, not a hypothetical**: calling the returned
handle's `request_redraw()` from the background thread crashed the process
(`SIGABRT`, `Result::unwrap() on an Err value: JavaException`) the first
time an Android transcript fetch called it a second time. `android/render.rs`'s
`AndroidRedrawHandle` was already attaching the calling thread to the JVM
correctly, but its `request_redraw` called `View::post_frame_callback`,
whose Java side calls `Choreographer.getInstance()` -- which throws unless
the *calling* thread already has a `Looper`, and a tokio worker thread,
even freshly JNI-attached, has none. Fixed by routing through
`View::post_delayed(0)` instead (Android's own thread-safe "queue work onto
this View's UI thread" primitive, needing no caller-side `Looper`), landing
on a new `IrisViewPeer::delayed_callback` override that drains tasks and
renders -- same body as `do_frame`, on the UI thread where
`post_frame_callback` is safe again. Any future caller of `redraw_handle()`
from a background thread gets this for free; nothing about the fix is
specific to the transcript screen.
## 2026-09-05: `transcript_ui::build_tree` (RUST.md's E4)
`transcript_ui::build` claimed the whole window (`ui_state.set_root(tree)`)
as its last step, which is right for a window that *is* the transcript
screen (the winit example, an eventual Android cdylib) and wrong for the
desktop app, which puts a session list beside it. `build_tree` is `build`
minus that last step: it returns `(TranscriptScreen, StrongWidget)` instead
of just `TranscriptScreen`, and the caller decides where the tree goes —
into `ui_state.set_root`, or into a `WidgetPtr` alongside something else
(`iris/desktop-app`'s `rebuild_transcript`). `build` is now one line calling
`build_tree` and doing the `set_root` itself, so existing callers are
unaffected.
```rust
// before, and still available, for a caller that wants to *be* the window:
let screen = transcript_ui::build(rsc, &mut ui_state, rows);
// new, for a caller embedding the screen beside something else:
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
some_widget_ptr(rsc).set(tree);
```
## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
New public type, `iris::sense::DragArbiter`. Why: a widget author who
registers both a list-level pan and a row-level drag-to-select on the same
touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s
`run_sensors` always gives the innermost layer first refusal, so the inner
one wins every frame it is pressed, not just the frame the press started
(this is exactly what left transcript-ui's touch-drag panning unreachable
until now). `DragArbiter` is one small state machine, one instance per
gesture surface (a whole list, not per row), that a caller drives with its
own `press_start`/`update`/`release` calls and a caller-supplied `Instant`
(so it is unit-testable without a real clock or a render harness). It
decides the way Android itself does: an ordinary vertical drag pans
immediately; a stationary press held `LONG_PRESS` (500ms) starts a
selection, which any further drag then extends; a horizontal drag while
something is already selected extends it immediately, skipping the wait.
```rust
// One per list, held alongside whatever state coordinates the rows:
let mut arbiter = DragArbiter::new();
// On press-down:
arbiter.press_start(pos, Instant::now(), already_selected);
// Every frame the button/finger stays down:
match arbiter.update(pos, Instant::now()) {
DragOutcome::Pan(dy) => list.scroll(-dy),
DragOutcome::SelectStart => selection.begin(...),
DragOutcome::SelectExtend => selection.extend(...),
DragOutcome::Undecided => {}
}
// On release:
arbiter.release();
```
`transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is
the reference caller: every row's `CursorSense::click_or_drag() |
CursorSense::unclick()` handler routes through one `Selection`-owned
arbiter instead of calling `begin`/`extend` directly, so a drag that starts
on a row's own rendered text now pans the list correctly instead of
always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s
`drag_arbiter_tests` module.
### 2026-09-05, later: `DragArbiter::is_idle()`, recovering a missed `press_start`
Follow-up to the above, from a real touch-scroll dropout: a gesture's
`ACTION_DOWN` can land on a caller's own dead space (a row's padding, a
gap, a header with no handler) that never calls `press_start`, so the
first frame the arbiter actually sees is a `Pressing`-shaped `update`
with no matching start. Before this, `update`'s `Idle` arm had no way to
tell that apart from "nothing is happening" and answered `Undecided`
forever for the rest of that gesture. `is_idle(&self) -> bool` lets a
caller notice the gap and recover: if `is_idle()` is true on a frame the
caller knows a press is genuinely down (its own `Pressing`/equivalent
sense fired), call `press_start` right there instead of assuming one
already happened. `transcript-ui`'s `Selection::drag` is the reference
caller — one new match arm, checked before the ordinary `update`-only
case. Any other `DragArbiter` caller with the same "one sensor per
sub-region, no fallback for dead space" shape has the same gap and wants
the same recovery.
## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5)
A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size,
family, ...) for its whole string, applied via `push_default` into parley's
ranged builder. `SpanStyle` is a second, optional layer: a byte range plus
whichever of colour/family/font size/bold/italic/underline it overrides,
pushed with parley's own `push(property, range)` instead. Why: a transcript
row's markdown (a heading, **bold**, `inline code`, a link) all inside one
wrapped paragraph needs each to carry its own look while the paragraph
still wraps and selects as a single buffer — the thing `masonry`'s
`TextArea` cannot do (`StyleSet` is one style for the whole editor,
`text_area.rs:43-44`'s `// TODO: RichTextInput`), and the reason this
existed at all.
```rust
let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0);
wtext(text)
.spans(spans) // new: TextBuilder::spans, on both Text and TextEdit
.editable(EditMode::MultiLine)
.add(rsc);
```
Two things a widget author should know before reaching for it:
- **Call `.spans()` before or after `.editable()`, both work** — the field
lives on `TextBuilder` itself, not either output type, and both
`TextOutput::run` and `TextEditOutput::run` apply it to the buffer via
`TextBuffer::set_spans`. **These two call sites are a pair**: adding a
third `TextBuilderOutput` impl without also calling `set_spans` there
reproduces the exact bug this box shipped once already (spans silently
dropped for `TextEdit`, found only by screenshotting, not by any test —
`markdown.rs`'s own unit tests check string/range logic, which is
correct in isolation and proves nothing about whether the render path
ever sees it).
- **Colour is now per-glyph, not per-buffer.** `PlacedGlyph` gained a
`color: UiColor` field (from parley's own per-run `Style::brush`), and
`Painter::glyphs` draws each glyph in its own colour instead of
`RenderedText::color` uniformly. `RenderedText::color` still exists (the
buffer's *base* colour, for a caller that wants it as a whole, e.g. to
tint a cursor) but no longer drives what a glyph actually renders as.
## 2026-09-05: accessibility names via AccessKit (RUST.md's I4)
`.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree)
is now load-bearing: it's the one thing that puts a widget in the AccessKit
tree `iris_core::ui::access::AccessTree` builds and both backends push
out. A widget author who wants a control to be findable by name (and
tappable by name, through `ui-trace`/a real screen reader) calls `.label()`
on it; nothing else is required, and a widget nobody labels is invisible
to this system at zero cost, not just zero UI.
```rust
let button = rect(Color::LIME)
.on(CursorSense::click(), move |_, rsc| { ... })
.label("Add task"); // now findable by uiautomator/AccessKit as "Add task"
```
Two new things a widget author might touch directly:
- **`Widget::access_role(&self) -> accesskit::Role`**, default `Unknown`.
Override it if your widget has a real platform equivalent —
`TextEdit` now returns `TextInput`/`MultilineTextInput` by `EditMode`.
Only consulted for a widget that also has a `.label()`; an unlabelled
widget's `access_role` is never called.
- **`Widgets::named() -> impl Iterator<Item = WidgetId>`** — every widget
with an explicit label, for anything else that wants to walk the same
set `AccessTree` does.
Nothing about `Painter`, `draw`, or the layout/move machinery changed —
this sits entirely beside them, reading `resolved_region`'s output rather
than participating in producing it.
## 2026-09-05: `List`, a virtualised bottom-anchored list (RUST.md's I3)
A new widget, `iris::widget::List` (`iris/src/widget/list.rs` -- read its
module doc first), for the transcript's kind of screen: variable-height
rows, keyed by a `u64`, composed only while visible, moved rather than
re-laid-out on scroll, a scroll anchor that survives a row inserted above
it, "more" sentinels at each end, and "hold the edge nearest the tap" when
a row's height changes (`note_tap`, resolved in the layout pass).
```rust
let mut list = List::new(Axis::Y);
list.push_back(ListRow::new(key, row_widget)); // O(1)
list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected
list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge
list.note_tap(viewport_y); // before mutating a row's height
let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible
```
Built entirely out of existing primitives (`Painter::widget`/`widget_within`/
`reposition`/`draw_twice`, and `draw_inner`'s own old-children diffing) --
no new mechanism was added to the render core for it. One correctness
lesson worth reading even for other widgets: a row that fills whatever
region it is offered (`Rect`, `is_size_independent`) cannot be measured at
a throwaway oversized region and then merely `reposition`ed into place --
`reposition` only ever writes an offset, never a size, so the oversized
primitive stays oversized. `List` fixes this by caching each row's real
height once measured and placing an already-known row directly at its
exact box; see `list.rs`'s `place` for the full reasoning and
`a_fill_shaped_background_is_not_left_oversized` for the regression test.
## 2026-09-05: a second backend (android-view), and what moved to make room for it
RUST.md's I2. Three changes a widget or app author would notice, all in
service of the same thing: `default` (winit) and the new `android`
(android-view) backends sharing what does not depend on windowing.
- **`Selector`/`Selectable`'s bound changed from `Rsc::State:
HasDefaultUiState` to `Rsc::State: FocusHost`** (new trait, `attr.rs`).
`HasDefaultUiState` still exists and still works — `default/attr.rs` now
implements `FocusHost` for anything that has it — so a winit app's
existing code is unaffected. An Android app implements `FocusHost` via
`HasAndroidUiState` instead. Affects only an app that referenced
`HasDefaultUiState` directly at a `Selectable`/`Selector` call site
rather than through `.attr::<Selectable>(())`, which nothing in-tree
does.
- **`Tasks::init` takes `Arc<dyn RequestRedraw>` instead of
`Arc<winit::window::Window>`.** `RequestRedraw` (`task.rs`) is one method,
`fn request_redraw(&self)`; `winit::window::Window` implements it
(`default/render.rs`), so `Tasks::init(window)` at a call site is
unchanged by inference. Only matters if something constructed a `Tasks`
directly rather than through `DefaultRsc`/`AndroidRsc`.
- **`TextEdit::apply_event`/`TextInputResult` are `#[cfg(not(target_os =
"android"))]`** — they take a `winit::event::KeyEvent`, which does not
exist on Android; `android/input.rs` drives the same primitives
(`backspace`/`delete`/`motion`/`insert`, all still unconditional) from
`ndk::event::Keycode` directly instead. New unconditional getters on the
way: `TextEdit::text()`/`selection_range()`/`caret()`, and
`TextEditCtx::delete_byte_range`/`set_cursor_byte` — the primitives
`android/ime.rs`'s `InputConnection` bridge needed and that were not
previously exposed publicly.
## 2026-09-04: `Widget::draw` reports the size it used; `desired_width`/`desired_height` are gone
A widget used to implement three methods (`draw`, `desired_width`,
`desired_height`); it now implements one, `fn draw(&mut self, painter: &mut
Painter) -> Size`, which draws into `painter.region()` and returns how much
of it was used. Why: the two extra methods routinely re-simulated what
`draw` was about to do anyway (`Span::desired_ortho` copied its own draw
loop to get cross-axis sizing right) — one visit per widget per frame
instead of up to three. A container that needs a child's size before
placing it (alignment, centering) draws the child once at a provisional
region, reads the returned `Size`, and calls the new `Painter::reposition`
to move it into its final spot — an O(1) offset write, not a second draw. A
widget whose drawn output never depends on the size it's given (a
fixed-size `Rect`, a decoded `Image`) overrides the new `fn
is_size_independent(&self) -> bool { false }` to `true`, which skips
redrawing it when only its offered region changes shape.
```rust
// before
fn draw(&mut self, painter: &mut Painter) { /* ... */ }
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
// after
fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
```
`SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
design, the move-offset mechanism this shipped alongside, and the file
list.
## 2026-09-04: texture pipeline rebuilt off the binding array
`Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all
changed shape. Why: the old pipeline bound every texture ever drawn in one
`binding_array<texture_2d<f32>>` and asked every device, unconditionally,
for `VK_EXT_descriptor_indexing` — a real share of Android GPUs lack it,
and it failed outright on the Android emulator's software Vulkan. See
TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04".
- **`UiRenderNode::new` drops its `limits: UiLimits` parameter, and
`UiLimits` is gone.** Before: `UiRenderNode::new(&device, &queue,
&config, UiLimits::default())`. After: `UiRenderNode::new(&device,
&queue, &config)`. Nothing replaces it — there are no more
binding-array limits to size.
- **`src/default/render.rs`'s device request asks for no features and no
binding-array limits.** Before: `required_features:
Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`
plus two `max_binding_array_*` limits. After: `Features::empty()` (the
`DeviceDescriptor` default) and only `max_buffer_size` set, which was
never about the binding array.
- **`TextureHandle` has no `primitive()` method any more**; a caller
outside `iris` shouldn't have been calling it (it fed the old renderer's
internals), but if something did: use `image_index()` for a standalone
image's bind-group index. There is no equivalent for a page — a page has
no bind group of its own now, see below.
- **`GlyphPrimitive` has no public constructor from a struct literal.**
Before: `GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color,
flags }`. After: `GlyphPrimitive::new(uv_min, uv_max, layer, color,
flags)` — one `layer` (the shared atlas array's layer) instead of a
`view_idx`/`sampler_idx` pair, since a page is now a layer of one array
texture rather than its own bound texture.
- **A widget author drawing images is unaffected**: `Painter::texture`/
`texture_at`/`texture_within` and `Textures::add` keep their signatures.
What changed underneath is that each standalone image now gets its own
`wgpu::BindGroup` and draw call instead of a slot in the shared array —
invisible from the widget API, visible only in `UiRenderNode`'s internals
and in `iris`'s device requirements.
## 2026-09-05: `FrameReport` splits each frame at `queue.submit`
`FrameStats` gains two fields, and `FrameReport` gains a second recording
method, to answer "is a slow frame iris's own CPU work or the driver/GPU"
with a number instead of a guess (RUST.md's I5 box).
- **`FrameReport::record_split(total, submit_to_present)`** is a second way
to record a frame, alongside the existing `record(total)` (unchanged,
and still what a caller with no split should use — it now reads as
`cpu_p50 == total`, `gpu_wait_p50 == 0`, rather than fabricating a
number for a half it never measured).
- **`FrameStats` gains `cpu_p50` and `gpu_wait_p50`**: medians of
redraw-start-to-submit and submit-to-after-`present()` respectively,
independent of each other and of the existing `p50`/`p90`/`p99`/`worst`
(which are unchanged, and still over the whole frame). The Android
renderer's `draw()` now returns the `submit_to_present` `Duration` it
measured, which `android::view::render()` passes to `record_split`.
- **Caveat carried in both doc comments**: `submit_to_present` is not
fenced against the GPU actually finishing — it is "how long the CPU was
blocked handing the frame to the driver," not a confirmed GPU-completion
time. Enough to separate "iris is slow building the frame" from "iris is
slow handing it off," not enough to claim an exact GPU budget.
+352
View File
@@ -0,0 +1,352 @@
# iris: known problems and things still to build
Iris's own list for the library, recorded 2026-09-04 in her words where it
matters, so the agents working through RUST.md pick these up in a sensible
order rather than rediscovering them. Each item says where it sits in the
order and what "done" looks like. Tick and date them in place.
## Fix
- [x] **Input does not fall through by input type (2026-09-04).**
`SensorUi::run_sensors` (`src/default/sense.rs`) used to set "consumed,
stop checking lower layers" from mere hover — a widget registered for
nothing but `click()` blocked a `Scroll` meant for whatever was behind
it, since "the cursor is over this widget" and "this widget handled the
event" were the same check. Fixed by judging consumption per input
kind: with no button transition and no scroll happening this frame
("momentary" activity), the topmost hovered widget still wins, same as
before; when something momentary *is* happening, only a widget whose
registered senses actually include a matching non-hover one (checked
via a new `TypeEventManager::registered`, which lists what a widget
registered without running anything) consumes it, so a widget with only
`Hovering`/click handlers can no longer block a scroll from reaching a
list underneath. `iris/src/sense_tests.rs` builds a button-over-a-list
`Stack` with a plain `HasEvents` impl (no GPU or window) and checks both
directions: a scroll over the button reaches the list, and a real click
still reaches the button — confirmed to fail on the pre-fix code and
pass after.
- [x] **Appending one image to an already-loaded list rebuilds every other
image's bind group (2026-09-05, fixed 2026-09-05).** Found by the
benchmark below: `GpuTextures::update` (`core/src/render/texture.rs`)
triggered `rebuild_image_bind_groups` — a loop over *every live
standalone image*, rebuilding its `BindGroup` — whenever the shared
`masks` or `move_offsets` GPU buffer was resized (`masks_resized ||
moves_resized` in `UiRenderNode::update`, `core/src/render/mod.rs`), and
a widget getting its *first* move-offset slot (LAYOUT.md section 2 —
every widget gets one on first draw) could be exactly what grows that
buffer. So one new message with one new image, appended to a transcript
that already has N images loaded, did not cost O(1): it cost one
`create_image` for the new image plus one `make_image_bind_group` per
*existing* image, because the new widget's own move slot pushed the
arena past its capacity. Measured directly in
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
already-settled ones reported **1,001** bind-group creates for that one
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
**Fix**: `masks`/`move_offsets` never belonged in a standalone image's own
bind group (group 2) in the first place — the group also holds that
image's own texture view, which is the only thing that is genuinely
per-image, so a buffer shared by *everything* forced a rebuild of
*every* group the moment it moved. Gave masks/move_offsets their own
bind group (group 3 in `shader.wgsl` and `UiRenderNode`: `masks_layout`/
`masks_group`), bound once per frame in `UiRenderNode::draw` rather than
once per draw call, instead of duplicating them into every per-image
group. `GpuTextures` and its image bind groups now know nothing about
either buffer — `rebuild_image_bind_groups` is called only from
`grow_array` (the atlas array texture growing, which genuinely does
change what every image's own bind group must reference) — so a
masks/move_offsets resize now touches exactly one bind group, ever,
regardless of how many images are live. Numbers after the fix, same
benchmark and command:
./run-bench.sh images
frame=1 bind_group_creates=1000 (cold load, unchanged)
frame=2 bind_group_creates=0 (was 1000 -- see the item below)
frame=3 bind_group_creates=0
frame=4 bind_group_creates=0
(append one image here)
frame=5 bind_group_creates=1 (was 1001)
frame=6 bind_group_creates=0
`run-headless.sh tabs --shot` still 27266 bytes, byte-for-byte unchanged,
confirming the bind-group restructuring changed nothing about what is
drawn.
- [x] **Bind-group creation takes two frames to reach the steady state, not
one (2026-09-05, closed by the fix above, 2026-09-05).** Same benchmark:
loading 1,000 images cold used to report 1,000 creates on frame 1
(expected — `create_image`, one per new image) *and again* 1,000 on
frame 2, before settling to 0 from frame 3. This was `rebuild_image_bind_groups`
firing a second time for the same masks/move-offsets buffer-growth
reason as the item above, confirming the guess recorded here — the two
were exactly the same root cause measured two different ways. Frame 2
now reports 0 (see the numbers above); not a separate fix.
## Build
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
`benches/` or a script under `iris/`, never in `cargo test`). The
scenario that matters most is a **message list** — chat apps and this
app's transcript alike — stressed with many messages and many images.
One case in particular: **resizing an input box** (typing enough text to
grow it) that pushes a long list of messages above it must stay very
fast and recalculate almost nothing — a move of everything above, not a
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
benchmark is what proves it. Done when the numbers are in this file with
the command, and the input-box case reports draws re-run, not just frame
time.
**Built as two rigs**, chosen per scenario by whether a real `wgpu`
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
most of this runs as an ordinary binary — the same property
`layout_tests.rs` relies on):
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
the file's own header for why (short version — every scenario here
reduces to a *count* `UiRenderState::take_counters` already produces,
which criterion's statistical machinery adds nothing to and which a
new dependency is not worth pulling in for). Covers (a) first-frame
cost of a message list of N wrapped-text rows (one in 20 also carrying
a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost
of scrolling that list, 200 ticks; (c) the input-box case — a
fixed-height field at the bottom of the screen growing by a line 40
times, with the message list above it filling the rest of the screen.
Run: `cd iris && cargo bench --bench message_list` (always release —
`cargo bench` builds the `bench` profile, which is optimized).
- `iris/examples/bench_images.rs` — needs a real device, so it runs
through `iris/run-headless.sh bench_images`, printing
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
mirroring `UiRenderState::take_counters`) each frame. Covers (d): 1,000
image rows, checked both cold (does bind-group creation reach zero
once loaded) and after appending one more image once settled (does
*that* stay cheap) — the second question is what actually matters for
a live transcript and is what turned up the two Fix items above.
- `iris/run-bench.sh [list|images]` runs either or both and is what to
run before/after touching `Scroll`, `Span`, `Sized`, the move-offset
chain, or `GpuTextures`.
**Numbers (2026-09-05, release, `cargo bench`/`run-headless.sh`, this
VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):**
cd iris && cargo bench --bench message_list
(a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0
(a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0
(a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0
(b) scroll, N=100/1000/10000, 200 ticks each:
draws=200 rewrites=0 moves=200 (identical at every N)
per-tick average: 0.0002ms (identical at every N)
(c) input grows 40 lines, N=100/1000/10000 rows above it:
draws=320 rewrites=40 moves=160 (identical at every N)
per-line average: 0.0012-0.0013ms (identical at every N)
cd iris && ./run-bench.sh images (2026-09-05, before the fix)
frame=1 bind_group_creates=1000 (cold load)
frame=2 bind_group_creates=1000 (see Fix item above)
frame=3 bind_group_creates=0
frame=4 bind_group_creates=0
(append one image here)
frame=5 bind_group_creates=1001 (see Fix item above)
frame=6 bind_group_creates=0
cd iris && ./run-bench.sh images (2026-09-05, after the fix)
frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work)
frame=2 bind_group_creates=0
frame=3 bind_group_creates=0
frame=4 bind_group_creates=0
(append one image here)
frame=5 bind_group_creates=1 (one image's own create_image, O(1))
frame=6 bind_group_creates=0
**Reading it**: (a) is real, necessary work — shaping and laying out N
never-before-seen text rows — and scales with N as it must, ~10x cost
per 10x N. (b) and (c) are the pass conditions that matter: both are
**exactly flat across N = 100 to 10,000**, confirming LAYOUT.md's O(1)
move chain holds for both scrolling and for a growing input box pushing
the message list — draws/moves per tick or per line do not grow with
list size, and the per-operation cost (a fraction of a microsecond) is
nowhere near a frame budget. (d)'s cold-load and steady-state halves
behave as designed; its *append* half did not, until the fix above moved
masks/move_offsets out of the per-image bind group — now flat at O(1)
the same way (b) and (c) are.
- **I5's transcript screen (`iris/transcript-ui/`, 2026-09-05) — what it
left, each recorded at the point in the code it would go rather than
silently dropped. See RUST.md's I5 box for the full account of what
*was* built (the screen, `SpanStyle`, cross-row selection, the growing
composer).**
- [x] **Android integration for this screen — done, 2026-09-05.**
`iris-android-app`'s `transcript-screen` Cargo feature
(`transcript_client.rs`) runs this screen against a real `ai-server`
through `client-core`, confirmed on-device: real scrolling, real
touch-drag panning, tap-by-name on the composer. Two real bugs found
and fixed along the way (a missing `INTERNET` permission; a
background-thread redraw request that crashed via a `Looper`
requirement, fixed by routing through `View::post_delayed` — see
`IRIS.md`'s `Tasks::redraw_handle` entry). See RUST.md's I5 box,
"The Android integration, done 2026-09-05" for the full account.
- [x] **A render-time number for iris, comparable to Compose's
`transcript-bench.sh` report — instrumentation done and a real number
obtained, 2026-09-05 (later the same day); the clean comparable loop
is not.** `iris_core::FrameReport` (`iris/core/src/render/
frame_report.rs`, `IRIS.md`'s new entry) times every frame from
`render()`'s redraw start to after `queue.submit`+`present()`, exposed
as two named on-screen controls ("Frame report", "Reset frame
report"). Driven against a real on-device touch-drag it read
`frames=34 janky%=61.76 p50=26.5ms p90=48.0ms p99=98.1ms
worst=98.1ms` — real, not inferred, but accumulated across several
gestures rather than one clean 24-swipe loop, because of the new
finding below. See RUST.md's I5 box, "Update, 2026-09-05, later the
same day" for the full account.
- [ ] **New, 2026-09-05: intermittent touch delivery to iris's
`SurfaceView` under this checkout's `EMU_GPU=software` emulator.**
The same swipe coordinates, confirmed (by scanning a screenshot
column for the first non-black pixel) to sit over real row text,
sometimes produced 30+ real frames and a screenshot diff and
sometimes produced zero of either, across otherwise-identical
`ui-trace` invocations. Not the already-understood "already at that
scroll edge" case (reproduced with content confirmed taller than the
viewport, in both directions). Leading candidate, not yet confirmed:
this checkout's emulator was independently observed at ~78% of one
CPU core, continuously, while idle on-screen — SwiftShader's software
rasterisation is CPU-bound by design, and a synthetic touch competing
with that load for delivery is plausible but unmeasured *during* a
failing gesture (the standing rule against diagnosing from
after-the-fact measurements applies here). Needs a sampler (load,
`dumpsys input`, a `-i 0` `ui-trace` capture) running while a failing
gesture is driven, and ideally a comparison under `-gpu host` (real
Vulkan) to see whether it is specific to software rendering. This is
what blocks the clean, comparable 24-swipe loop above.
- [x] **Long-press-then-drag-to-select — confirmed on-device, 2026-09-05
(later the same day).** `ui-trace` gained a `holddrag X1 Y1 X2 Y2
HOLD_MS MOVE_MS` action (`emulator-tools`, additive, extends the same
`MotionEvent`/`injectInputEvent` mechanism `swipe` already used):
press, hold past `LONG_PRESS`, move, release, as one continuous touch.
Driven against a real row (`holddrag 300 1850 300 2050 600 300`) it
produced `iris selection: begin at row ...` then a sequence of
`iris selection: extend to row ...` log lines
(`transcript-ui/src/selection.rs`, a new small `log` dependency since
selection has no accessibility label of its own yet — see the next
item), and a screenshot taken right after shows the expected
highlighted selection spanning multiple rows. `DragArbiter`'s own
unit tests already covered this sequence against a synthetic clock;
this is the first time it has been driven by a real device touch.
- [x] **Touch-drag panning over a row's own rendered text — done,
2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
registration won `core/src/sense.rs::run_sensors`'s per-layer
arbitration on every frame it was pressed, not just the frame the
press started, so a list pan gesture registered on `List` itself never
got a turn while a row was under the finger. Fixed with
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
machine per list deciding pan vs. select the way Android does (a
vertical drag pans immediately; a stationary press held `LONG_PRESS`
(500ms) starts a selection which further drag extends; a horizontal
drag while something is already selected extends immediately) —
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
every row's drag now routes through. 8 new unit tests
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
clean; `run-headless.sh` screenshot byte-identical to before the
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
- [x] **Intermittent touch-scroll dropout — root-caused and fixed,
2026-09-05.** Not the coalesced-`ACTION_MOVE` hypothesis the earlier
pass suspected (ruled out): a gesture's `ACTION_DOWN` can land on a
row's own padding/gap or its header, which no `CursorSense` covers,
so `DragArbiter` never gets `press_start` and sits in `Idle`
(answers `Undecided` forever) for that whole gesture. Fixed via a new
`DragArbiter::is_idle()` that `Selection::drag`
(`transcript-ui/src/selection.rs`) checks to recover a missed press
on the next `Pressing` frame. Four new unit tests. See RUST.md's I5
box, "Touch-scroll dropout root-caused, 2026-09-05", for the trace and
what a peer session sharing this checkout's emulator mid-pass
prevented from being re-verified end-to-end (the aggregate
`iris-scroll.sh` three-run confirmation and a re-taken FrameReport
row) — a future pass should finish that once the emulator is free.
- [ ] **Row-level accessibility names.** The composer carries
`.label("Message")`; transcript rows do not carry a `.label()` of
their own yet, so `Widgets::named()` (I4) does not include them —
`row.rs`'s `build_text_row` is where one would go, keyed to something
stable per row (its sender + a short excerpt, matching what a screen
reader announcing a chat message would say).
- [ ] **A tappable link and a background chip behind inline code.**
Both need per-range glyph geometry that `TextEditCtx` does not expose
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
private) — see `markdown.rs`'s module doc for the exact shape the fix
would take (the same primitive `TextEdit::draw`'s own selection
highlight already uses internally,
`iris/src/widget/text/edit.rs:99`).
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
is selected in full (`select_all`) the moment the drag leaves it,
rather than "from the click point to whichever edge points away from
the drag" — needs the same private `layout()` access as the item
above. `selection.rs`'s module doc has the exact reasoning.
- [ ] **No syntax highlighting inside a fenced code block.**
`client_core::highlight` exists (built for the file explorer) and
could feed per-token `SpanStyle`s into a code block's span; wiring it
in was not attempted this pass.
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
by something *and also* applies mask B — a mask can reference a parent
mask, the way the move chain references a parent offset. Today masks
are independent regions. Design it beside the move chain (same shape:
a parent index and a bounded walk in the shader); do it when a real
widget needs it, not before.
- [ ] **Positions as a single float per scroll.** Iris raised, and half
rejected, letting a scroll update one float rather than positions:
input handling cares about most elements in a list, so absolute
positions must be computed on the CPU anyway. LAYOUT.md's design
already lands here (GPU walks the chain, CPU resolves on demand for
hit tests). Keep the CPU resolution lazy and per query; do not
materialise every row's absolute position per frame.
- [ ] **Animations, last.** Cosmetic, so after everything above. Must be
**modular — a piece of the library rather than a core part forced into
everything, the same way input is**. Whatever the mechanism, a widget
that does not animate must pay nothing and import nothing for it.
## Build (for the port)
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
iris does not have yet, one entry per gap, named against the P-step that
first needs it. Move an entry up to "Fix" or tick it in place once built;
do not duplicate it there.
- [ ] **A history-paging cushion measured in on-screen viewports, not a
row count.** (**P1**.) `iris::widget::List` has no equivalent of the
Compose app's `HISTORY_SCREENS` — AGENTS.md's "Things that have
bitten" is explicit that a fixed row count under-fills a screen on a
tool-heavy transcript and over-fills one on a text-heavy one, so
whatever loads the next page has to ask the list how many viewports
are actually on screen, not assume a constant.
- [ ] **A scaled thumbnail/image widget for an in-transcript image.**
(**P1**.) `SessionImage.kt`'s bitmap decode-and-downscale has no iris
counterpart; iris's own image widget (used by `bench_images.rs`) draws
a loaded texture but does nothing about sourcing or scaling one from a
server-produced attachment.
- [ ] **A modal/dialog primitive.** (**P1**, reused by **P3** and
**P5**.) Needed for the session settings dialog, `UsageDialog`'s
equivalent, and the delete-with-`deleteForeign` confirmation with its
toggle switch. Build once, wherever it is first needed, rather than
once per screen that wants one.
- [ ] **A horizontal gauge/bar widget.** (**P1**.) For
`SessionUsageBar`'s equivalent — a bounded fill reflecting a fraction,
nothing fancier.
- [ ] **A `BusyItem` equivalent: a dimmed row carrying an operation
label that does not block its list's own scroll/drag.** (**P3**.) The
Compose version tried an overlay first and it swallowed the drag along
with the tap (AGENTS.md's "Shared appearance") — worth not repeating
that attempt in iris before building the row-level version directly.
- [ ] **A toggle switch.** (**P3**.) For the delete dialog's
`deleteForeign` control; iris has no switch/checkbox widget yet as far
as this pass found.
## Reconsider
- [ ] **`WidgetView`.** Iris is unsure of it: what she wants is an easy way
to compose a widget from others (a button is the main case). With
sizing folded into `draw`, composing may be easy enough that `View` is
redundant. Decide after the layout change lands, by writing a button
both ways and keeping the one that is shorter to explain; delete the
other rather than keeping two ways.
+897
View File
@@ -0,0 +1,897 @@
# iris: one `draw` that reports a size
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
any design or code so that it survives a cleared session. **Status: implemented
2026-09-04, against every pass condition in §8** (measured, not assumed — see
that section). Every widget listed in §7 was migrated in one change; none
kept `desired_width`/`desired_height`. Five points needed correction or
refinement beyond what this file originally specified — see "Deviations
found during implementation" below, added right before "For IRIS.md" — read
that section before touching `Aligned`, `Sized`, `MaxSize`, `Scroll`, or the
move-slot lifecycle in `render_state.rs`, since each of those five is a real
bug this file's first draft would have reproduced if implemented literally.
## What Iris asked for
> I don't like that widgets need both a draw and size functions. I'd much
> rather them have a single draw that reports a size, and if it needs to be
> moved then that can be done after the fact efficiently, or resized just
> done after as well. This should be done efficiently like everything else
> tries to do right now.
She added, a few minutes later: "single draw is not a requirement. It
just seems more efficient from what I've heard. Feel free to override any
decision I've made if you can find a genuinely better & still clean
alternative." So the single-draw model is the default to design against,
and the design below may reject it, but only with a written comparison
showing the alternative does less work per frame and is no harder to use.
Standing constraints from RUST.md still apply: no DSL, plain Rust, do as
little processing as possible per frame, but the model must cover every
layout need a real app has (the transcript's virtualised list, wrapped
text whose height depends on width, rows and columns that size to their
children, overlays, masks).
## What exists today
`Widget` (`iris/core/src/widget/mod.rs`) has three methods: `draw(&mut
self, &mut Painter)`, `desired_width(&mut self, &mut SizeCtx) -> Len` and
`desired_height`. A parent asks `SizeCtx::width/height` for a child, which
is memoised per widget id and axis in `Cache.size` keyed on the outer
size, then places the child with `Painter::widget_within(region)`. So a
child is visited twice (sized, then drawn), every widget implements sizing
twice (one per axis), and a widget whose size depends on what it draws
(wrapped text, a laid-out paragraph) does the layout in the size pass and
again in the draw pass unless it caches by hand.
Primitives are already positioned by `UiRegion` values whose scalars have
a `rel` and an `abs` part, resolved against the window in the vertex
shader (`core/src/render/shader.wgsl`), and `Primitives::region_mut`
exists to rewrite one instance's region in place. That is the mechanism a
"move after the fact" can build on.
## What the design must answer
1. **Parent-before-child ordering.** A row has to know each child's width
to place the next one, but under "one draw" the child's size only
exists after it has drawn. The answer is meant to be: the child draws
at a provisional origin, reports its size, and the parent *moves* it.
The move must be O(1) per moved subtree, not O(primitives in the
subtree). One way: every instance carries an index into a small
per-widget offset buffer, so moving a widget writes one entry and the
vertex shader adds it. Other ways may be better; the design should say
what was considered.
2. **Move vs resize are different costs and must be kept apart.** A move
never re-runs `draw`. A resize re-runs `draw` for exactly the widgets
whose size input changed, and a widget whose output does not depend on
its size (an icon, a fixed rect) must be able to say so and be skipped.
3. **Size-dependent content.** Wrapped text is the hard case: its height
is a function of its width. A single `draw` receives the available
size (what `SizeCtx.outer` is today) and reports what it used, so the
two-pass "measure then draw" collapses into one for the common case.
The design must say what happens when a parent wants the child's
height *before* deciding the width it will offer (rare; say whether it
is supported, or is done by drawing twice as an explicit, opt-in cost).
4. **Caching.** Today's `Cache.size` memoises by (id, axis, outer). The
replacement should memoise the whole draw result by (id, available
size) so that an unchanged subtree costs nothing on the next frame,
which is what makes a virtualised list cheap.
5. **Everything currently written against `desired_width`/`desired_height`
moves over in one change**, per the code rules: two names for one
concept is not an intermediate state to leave behind. The widgets are
in `iris/src/widget/` (`ptr`, `mask`, `image`, `rect`, `trait_fns`, and
whatever else is there when the change is made).
## Order relative to the texture work
TEXTURES.md's redesign touches the render core (shader, `GpuTextures`,
`Primitives`, `Painter`'s texture calls). This change touches the widget
trait, `SizeCtx`, `Cache`, `Painter`'s widget calls, and any offset
mechanism the vertex shader needs. They overlap in `Painter` and the
shader, so they are done **in sequence, textures first**, and the layout
design here is written (not implemented) while the texture work is in
progress, then implemented on top of it.
## Design
### 1. The new `Widget` trait
```rust
pub trait Widget: Any {
/// Draw within `painter.region()` (the space the parent offered) and
/// report how much of it was actually used, per axis.
fn draw(&mut self, painter: &mut Painter) -> Size;
/// True if `draw`'s output (both the primitives it writes and the
/// `Size` it returns) is the same for any `painter.region()` of the
/// same *content* -- an icon, a fixed-size rect, an already-decoded
/// image at its natural size. Default `false` (redraw on any change to
/// the offered region) because assuming independence wrongly produces
/// a stale draw; a widget must opt in.
fn is_size_independent(&self) -> bool {
false
}
}
```
No `available` parameter: `Painter` already carries the region the parent
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
size (`output_size()`, `:152`). Passing it again would be the same value
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
deprecated, not kept as a fallback — because a widget that implements both
`draw` and `desired_*` for the same thing is exactly the "two names for one
concept" the code rules call out, and it is what today's `Span::desired_ortho`
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
own comment: "this literally copies draw so that the lengths are correctly
set in the context, which makes this slow and not cool." Folding sizing into
`draw` deletes that duplicate simulation, not just moves it.
**No single-draw alternative was found that does less work per frame.** The
two-method trait was checked against three properties a real screen needs —
a row placing children in sequence, a widget centering on its own content,
and wrapped text — and in every one, `draw` already has to visit the child
to get a size that is *this specific one's* answer, which today's
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
the same layout the draw pass will do again. So the two-method trait is not
"measure once, draw once" in the general case; it is "measure once per axis,
then draw once," i.e. up to three visits per widget per frame, against one
under the design here. The single-draw model is therefore adopted as
proposed, not merely accepted as a preference.
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
**What exists today, and why it is not O(1).** `UiRenderState::mov`
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
its *size* but changes *position* (`draw_inner`, `:85-100`:
`active.region.size() == region.size()` after excluding the exact-match
case). It rewrites every primitive's `region` field via
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
widget's own primitives, then recurses into every child — O(primitives in
the subtree). Both call sites that trigger it today, `Scroll::draw`
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
re-touches every glyph in every visible row, every frame of the drag, and
I3's target is 800 rows on screen.
**Recommendation: a per-widget offset slot forming a parent-linked chain,
resolved in the vertex shader.**
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
add nothing more"). A pure abs-pixel translation, not a general
`UiRegion` remap — sufficient for every existing call site (above).
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
kind of per-instance handle.
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
assigned **when the widget is first drawn** (`draw_inner`, beside
`active.insert`), with `parent` = the drawing widget's parent's slot.
`Painter` threads a `move_slot` field down exactly as it already threads
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
correct from its first frame — nothing is ever retrofitted onto an
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
- `mov(id, delta)` becomes: look up `id`'s slot, write
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
recursion, since descendants already reference this slot transitively.
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
pixels (after `:106`, before the clip-space divide at `:113`), walks
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
small constant, e.g. 16, with a CPU-side debug assertion that no chain
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
paid every frame regardless of whether anything moved — negligible next
to the per-fragment texture sampling TEXTURES.md already measures this
GPU as not bound by.
**Why the chain, not the flatter thing first proposed.** Iris's own
phrasing — "every instance carries an index into a small per-widget offset
buffer" — describes a flat table: one slot per subtree *declared* movable,
no parent link. It breaks the moment two such subtrees nest — a row inside
a scrolling list, itself later given its own animated offset (a
swipe-to-delete mid-scroll) — because the row's primitives would have to
pick one slot and lose the other's contribution. The chain costs one extra
field and a bounded shader loop in exchange for no such gap, and since
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
no more at the common depth of one than the flat version would.
**Against `region_mut` as the steady-state mechanism**: rejected for being
O(primitives in the subtree) — the cost this section removes — but kept
for a resize that changes a region's `rel` component (a genuine reflow,
§3) and for a size-independent widget's resize (§3), where the content's
shape doesn't change and one field write already suffices.
### 2b. Two more readers of "where is this widget," and masks
Moving the offset into the vertex shader means `ActiveData.region` is no
longer the on-screen truth once a widget has been moved — it is where the
widget was *drawn*, before any `move_offsets` delta. Two things read it as
if it still were, and both must move to a resolved query or they silently
answer with the pre-move position: a click landing on a scrolled row would
be routed to whatever used to be there, with nothing on screen to say so —
exactly the "wrong answer that looks like a right one" case the code rules
single out.
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
does the actual pointer routing, and line 170 is the read in question:
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
immediately turned into pixels and tested against the cursor at `:171-172`.
Under this design that region must be resolved through the same chain the
GPU walks before it means anything. Add to `UiRenderState`:
```rust
/// `active[id].region`, corrected by every `move_offsets` delta between
/// `id` and the root — the CPU-side twin of the vertex shader's chain
/// walk, over the same arena, so the two cannot disagree about where a
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
/// `move_offsets`, bounded by the same constant the shader loop uses
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
/// const across the language boundary).
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
```
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
coordinate query already used outside hit-testing
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
another's on-screen box), is reimplemented to call `resolved_region(id)`
before `.to_px(...)` instead of reading `.region` directly — one change
covers both call sites listed there. `sense.rs:170` changes to
`let shape = self.resolved_region(*id);`. Both are required the moment §2
lands, not an optional follow-up: an unmoved widget's chain is empty and
`resolved_region` costs one arena read to find that out, so there is no
version of this design where skipping the fix is a legitimate
optimization — it is a correctness gap, not a performance one.
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
painter's *current* region into a `Mask` pushed onto
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
widget that called `set_mask` — `Masked::draw`,
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
is itself later moved, its clip rectangle stays where it was drawn while
its content moves out from under it: a visibly wrong clip, immediately on
screen, not a latency question.
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
written from `Painter::set_mask` as `self.move_slot` — the identical slot
the mask-owning widget's own primitives already get (§2), not a second
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
currently computes the mask's `top_left`/`bot_right` inline from
`masks[in.mask_idx]`; that computation is extended to walk the same
move-offset chain §2 added, via one shared function —
```wgsl
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
```
— called from `vs_main` for a primitive's own corners and from `fs_main`
for its mask's corners, so the walk is written once and the two stages
cannot drift apart (the sibling-rule from the code rules: one loop, not a
hand-copied second one in the other shader stage).
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
primitive's mask is frequently owned by a *different* widget than the
primitive itself — often several levels up a subtree, with its own,
independent move slot — so a primitive's resolved offset and its mask's
resolved offset are two different chain sums, both needed, and only the
fragment shader has both `in.move_idx` (this fragment's own chain) and
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
in hand per-fragment. Resolving mask regions on the CPU at move time would
mean, for every `mov()` call, walking forward to every mask instance the
moved widget's slot could affect and rewriting its raw region — exactly
the O(subtree) cost §2 exists to remove, just moved from primitives to
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
frame (`:148`); one more arena read to resolve its chain costs nothing
extra in kind.
**The scroll-container case, checked rather than assumed.** A masked,
scrollable region is built as a `Masked` wrapping a `Scroll`
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
viewport border is drawn (and `set_mask` called) by `Masked`, which is
never itself the target of `mov()`; only `Scroll`'s inner content is,
every frame the user drags. Because each widget's move slot is its own
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
its own, stationary slot, while the scrolled content underneath references
a separate, deeper slot whose `parent` chain passes through — but does not
write to — the viewport's slot. Moving the content therefore never touches
the mask's resolved position, and the mask staying still while its content
slides past it is what this design already produces with no special case,
not an extra rule that had to be added for it.
### 3. Resize scope
A resize is "the region a widget's parent offers it changes such that the
widget's draw might produce different output" — as opposed to a move, which
by construction cannot (§2 is scoped to pure translation). Two independent
narrowings apply, and both are real, measured properties of the code as it
stands rather than new machinery:
**(a) A window resize does not, by itself, require touching most widgets.**
`shader.wgsl:105-106` recomputes every primitive's pixel position from
`window.dim` and the primitive's stored `rel`/`abs` pair *every frame,
already, on the GPU*. A widget laid out purely in `rel`/`abs` terms (no
call to `px_size()`, `output_size()`, or anything else that reads a
concrete pixel count) is therefore already correct after a resize with zero
CPU work — the shader did it. `UiRenderState::needs_redraw_all`
(`render_state.rs:229-231`) currently ignores this and redraws the entire
tree on every `resized`, which was the safe default while sizing and
drawing were two passes; it should be narrowed to only the widgets that
*do* read a concrete pixel value. Track this the same way `needs_redraw`
already tracks per-widget dirtiness (`Widgets::needs_redraw`,
`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself
pixel-dependent by calling through `Painter` methods that read
`output_size`/`px_size` (both already funnel through `Painter`, so the
marking is one line at each), and `resize()` (`render_state.rs:32-35`)
walks only that set instead of unconditionally setting `resized = true`
for a full `redraw_all`. This turns "every resize redraws everything" into
"every resize redraws what depends on pixels" — a real behavior change
beyond what was asked, so verify it against the I0b `pre_present_notify`
resize regression (that fix depended on `redraw_all`'s completeness)
before narrowing this.
**(b) A widget's `available` (its parent's offered region) can change
without the widget's *content* changing — this is what
`is_size_independent` (§1) answers.** When a container's own layout shifts
(a sibling grew or shrank, changing this widget's offered box), a widget
that returns `true` from `is_size_independent` is not redrawn: its
primitives are unaffected by size, only by placement, so the parent
either (i) issues a move (§2) if only position changed, or (ii) rewrites
the primitive's `region` fields directly via `region_mut` if the box
changed shape too (still O(primitives owned directly by this widget, not
its subtree, since a size-independent widget by definition has no
size-dependent descendants worth distinguishing — in practice this is
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
`false` (the default) is redrawn in full whenever `available` changes,
which is correct always, just not free.
**Ancestor propagation** (a resized child changing its own reported size,
requiring its parent to re-lay-out) is unchanged in spirit from today's
`redraw` (`render_state.rs:270-305`), which already walks up exactly the
ancestors whose cached size differs from the new one and stops as soon as
a size is unchanged (`:274-286`). That loop moves from consulting
`Cache.size` to consulting `ActiveData.size` (§5) but keeps its shape.
### 4. Wrapped text, and "needs child height before choosing width"
**Wrapped text is not a special case any more; it already reads as one
draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already
does exactly what single-draw asks for: it reads `ctx.px_size().x` as the
wrap width, shapes once, and memoizes the shaped layout keyed on that width
plus a changed-flag on the buffer and attrs (`:63-69`) — a second call with
the same width is a hash-map-style cache hit, not a re-shape. Under the new
trait this collapses `Text::draw`/`desired_width`/`desired_height`
(`text/mod.rs:133-147`, three functions) into one `Text::draw` that calls
`self.view.draw(painter)` once, which internally still calls `render`
once, hits its own cache, and returns the size it already computed. No
new caching is needed here; the two now-redundant call sites
(`desired_width`/`desired_height` each separately calling `render`) simply
disappear, which is a second `render` avoided per frame per text widget
that is being measured by a parent.
**"Parent wants the child's height before deciding the width it will
offer"** — the genuinely circular case named in the brief, e.g. a column
that sizes its own width to its widest child, where that child is wrapped
text whose height (which the column's *own* height depends on) depends on
the width the column has not yet decided. This is not solvable in one pass
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
(`span.rs:98-136`) already hits exactly this today and already resolves it
by an explicit second, throwaway pass (its own comment: "this literally
copies draw ... which makes this slow and not cool"). The design keeps that
resolution, made explicit rather than accidental: `Painter` gets
```rust
/// Draw `child` at a provisional region to learn its size under one
/// axis's worth of assumption, discard everything it wrote, then draw it
/// again at the region that assumption produced. For the rare parent that
/// cannot pick an offered size without already knowing the answer.
/// Twice the cost of one `draw`; every other case in this file avoids it.
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
```
implemented as: draw at `first`, record `Size`, remove the widget and its
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
"if not \[same region\], maintain resize and track old children," `:97-100`,
which already frees the old primitives before redrawing) — reusing that
path rather than adding a second one — draw again at `second(size)`, return
the final `Size`. It is opt-in and named for its cost, so a widget only
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
call site converted to it, replacing the hand-rolled duplicate loop.
### 5. Caching and invalidation
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
answer, independent of drawing) no longer exists as a separate query, so
there is nothing left to cache at that layer. What already provides "an
unchanged subtree costs nothing" is the check `draw_inner` performs before
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
its region is unchanged, and it is not marked dirty, `draw_inner` returns
immediately — no `Painter` constructed, no primitive touched, no shader
work beyond what the GPU already redraws from the unchanged instance
buffer. That check is kept exactly as it is; it is the caching mechanism,
and it already operates at (id, region) granularity, which subsumes "(id,
available size)" once size *is* what a region change means.
What is added: `ActiveData` gains `pub size: Size` — the value `draw`
returned, stored the moment it is (`draw_inner`, alongside building the
`ActiveData` struct at `:134-143`). This is what a parent placing this
widget for a second frame without redrawing it (because nothing changed)
reads instead of recomputing — it replaces `Cache.size`'s role of "answer a
size question without a full draw" with "read the size of the last actual
draw," which is always available because `draw_inner`'s skip path is only
reachable once the widget has been drawn at least once. `Cache::remove`/
`Cache::clear` (`cache.rs:9-17`) are deleted with the type; `ActiveData`
already has an equivalent lifecycle (removed in `remove`/`remove_rec`,
`render_state.rs:171-198`, freed with the widget).
### 6. Before / after
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
```rust
// before
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) {
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
thickness: self.thickness, inner_radius: self.inner_radius });
}
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
}
```
```rust
// after
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) -> Size {
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
thickness: self.thickness, inner_radius: self.inner_radius });
Size::REST // fills whatever it was given -- used == available
}
fn is_size_independent(&self) -> bool { true } // content never depends on region size
}
```
**A container that needs the child's size before placing it,
`iris/src/widget/position/align.rs`**:
```rust
// before
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) {
let region = match self.align.tuple() {
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
UiRegion::new(x, UiSpan::FULL) }
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
UiRegion::new(UiSpan::FULL, y) }
(None, None) => UiRegion::FULL,
};
painter.widget_within(&self.inner, region);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
}
```
```rust
// after
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) -> Size {
let full = painter.region();
// Draw once at the full region to learn the child's real size --
// this placement is provisional and corrected below without a
// second draw.
let used = painter.widget_within(&self.inner, full);
let region = match self.align.tuple() {
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
(None, None) => full,
};
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
used
}
}
```
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
return type from `()` to `Size`, carrying the child's `draw` result back —
the only signature change needed to let a parent see what its child used.
`Painter::reposition` is new, computing the delta between where a child
was actually drawn and where it belongs and calling the O(1) `mov` from
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
180-182`) are deleted — nothing calls `desired_len` any more, so there is
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
deletes the `SizeCtx` copies, keeping the `Painter` ones.
### 7. Migration — every file and widget that changes
One change, in dependency order (rename-and-move-together, per the code
rules — no intermediate state with both trait shapes):
- `core/src/widget/mod.rs` — the `Widget` trait (§1), delete
`WidgetAxisFns`, update `impl Widget for ()`.
- `core/src/ui/size.rs` — delete `SizeCtx` (the type and all its methods).
- `core/src/ui/cache.rs` — delete `Cache` (§5).
- `core/src/ui/painter.rs` — `widget`/`widget_within`/`widget_at` return
`Size`; add `reposition`, `draw_twice`; delete `size_ctx`, `size`,
`len_axis`; `primitive_at` writes `move_idx`.
- `core/src/ui/render_state.rs` — `draw_inner` captures and stores
`ActiveData.size`; `mov` becomes the O(1) offset write (§2); resize
narrowing (§3a); `redraw`'s per-axis loop reads `ActiveData.size`
instead of `Cache.size`.
- `core/src/ui/active.rs` — `ActiveData` gains `size: Size`,
`move_slot: MoveIdx`.
- `core/src/ui/mod.rs` — `UiData` gains `move_offsets`.
- `core/src/render/data.rs` — `PrimitiveInstance` gains `move_idx`;
new `MoveOffset` struct.
- `core/src/render/primitive.rs` — thread `move_idx` through `PrimitiveInst`
and `Primitives::write`, matching `mask_idx`.
- `core/src/render/mod.rs` — bind the new `move_offsets` storage buffer
(group 2, beside `masks`) and its update path.
- `core/src/render/shader.wgsl` — `InstanceInput` gains `move_idx`;
`MoveOffset`/`UiScalar`-shaped storage binding; a shared `resolve_move`
function (§2b) called from both `vs_main` (a primitive's own corners)
and `fs_main` (its mask's corners, once `Mask` carries `move_idx`).
- `core/src/ui/render_state.rs` — additionally, `resolved_region` (§2b)
and `window_region` (`:264-267`) reimplemented on top of it.
- `src/default/sense.rs` — `run_sensors`'s hit-test read (`:170`) switches
from `self.active.get(id).unwrap().region` to `self.resolved_region(*id)`
(§2b) — the pointer-routing fix this design requires, not an optional
follow-up.
- `core/src/render/data.rs` — additionally, `Mask` (`:46-49`) gains
`move_idx: u32` (§2b).
- `core/src/ui/painter.rs` — additionally, `set_mask` (`:49-52`) writes
`move_idx: self.move_slot` into the `Mask` it pushes (§2b).
- Every widget with a two-method `impl Widget`, collapsed to one `draw`
(§1, §6), `is_size_independent` added where true: `core/src/widget/mod.rs`
(`impl Widget for ()`), `iris/src/widget/rect.rs` (`Rect`, → true),
`iris/src/widget/image.rs` (`Image`, → true — a decoded image's primitive
never depends on the region it is offered, same as `Rect`),
`iris/src/widget/mask.rs` (`Masked`), `iris/src/widget/ptr.rs`
(`WidgetPtr`), `iris/src/widget/text/mod.rs` (`Text`, §4),
`iris/src/widget/text/edit.rs` (`TextEdit`),
`iris/src/widget/position/scroll.rs` (`Scroll`, keeps its `mov`-shaped
offset, now O(1) automatically via §2), `iris/src/widget/position/align.rs`
(`Aligned`, §6), `iris/src/widget/position/max_size.rs` (`MaxSize`),
`iris/src/widget/position/layer.rs` (`LayerOffset`),
`iris/src/widget/position/pad.rs` (`Pad`),
`iris/src/widget/position/stack.rs` (`Stack`),
`iris/src/widget/position/offset.rs` (`Offset`),
`iris/src/widget/position/span.rs` (`Span`, §4's `draw_twice` for the
cross-axis case, deleting `desired_ortho`'s duplicate loop),
`iris/src/widget/position/sized.rs` (`Sized`).
This list was produced by `grep -rn "impl Widget for\|fn desired_width\|fn desired_height"`
across `core/` and `src/`; re-run it before starting, since it is the
authoritative check that nothing was missed, not this paragraph.
- `iris/examples/{minimal.rs,task.rs,view.rs,tabs/main.rs}` — no direct
`impl Widget` found in any example (verified by the same grep); they use
the builder DSL in `core/src/widget/trait_fns.rs` and should need no
source change, which is itself part of the pass condition below.
### 8. Pass conditions
1. **Every example under `iris/examples` renders identically.** Run
`iris/run-headless.sh EXAMPLE --shot PNG` for each of `minimal`, `task`,
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
"looks right," since a subtle wrap or alignment regression is exactly
what a diff catches and a glance does not.
**Result (2026-09-04): pass, all four, 0 differing bytes.** No PNG
library is installed in this VM (no PIL, no ImageMagick, no pip), so the
diff is a from-scratch PNG decoder (`zlib` + the five filter types) at
`/tmp/layout-shots/pngdiff.py`, comparing decoded pixel bytes rather than
file bytes (`cmp` alone is not conclusive across two separately-encoded
PNGs, though it happened to agree here for `minimal`). Before-shots were
taken with `git stash` at the pre-change commit; `tabs` needed two real
fixes (deviations 1 and 2 below) before it stopped differing — the other
three matched on the first try.
2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside
the existing `debug_layers`/`active_widgets` instrumentation
(`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b)
`Primitives::write`/`region_mut` calls, both per `update()` call. Drive
one example (`tabs`, since it already has multiple widgets and an
interactive element) through one frame with nothing changed and report
both counts — the pass condition is **0 draws and 0 primitive rewrites**
for a frame in which nothing was marked dirty, resized, or moved.
**Result (2026-09-04): pass, 0 and 0.** Implemented as
`UiRenderState::take_counters() -> (u64, u64, u64)` (draws, `region_mut`
rewrites, `move_offsets` writes — a third counter, for condition 3
below), reset on read. Measured in
`iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing`
against a `Scroll` over 500 fixed-height rects (not the `tabs` example —
see the note on condition 3 for why this runs as a plain unit test
instead).
3. **Single-moved-child cost, measured.** Same counters, one frame in
which exactly one widget is moved (not resized) with N primitives in its
subtree — the pass condition is **1 write to `move_offsets`, 0 calls to
`Widget::draw`, 0 calls to `region_mut`**, independent of N. Construct
the case with a `tabs`-style example holding a deliberately large text
block (hundreds of glyphs) inside a `Scroll`, so N is large enough that
an O(N) regression would show up as a non-trivial write count rather
than being lost in noise.
**Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets
write, N = 500.** Built with rects rather than glyphs
(`iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw`):
`iris-core`/`iris` touch no GPU or window to lay out and move a tree, so
this runs as a plain `cargo test`, not through `run-headless.sh` — a
`Widgets`/`UiData` pair and a bare `UiRsc` impl are enough, and it is
faster and more precise than reading counters out of a real example's
stderr. Getting a clean single move took two follow-up fixes beyond the
design as written (deviation 3, the `parent_move_slot` threading; and
the `Scroll` design decision below about offering last frame's content
length) — without either, the count was in the thousands (every rect in
the subtree redrawing) rather than 1.
4. **Hit-testing follows the move, not just the render.** In the same
scrolled-`tabs` construction as condition 3, scroll the content, then
send a synthetic cursor position over a widget that moved and assert
`run_sensors` (`src/default/sense.rs:154-200`) routes to that widget's
id, not to whatever is now at its pre-scroll coordinates or to nothing.
This is a correctness check, not a timing one — §2b's fix is required
before §2 can ship at all, and this is what would fail silently
(nothing on screen indicates a missed or misrouted hit) if it were
skipped.
**Result (2026-09-04): pass**, but checked one level below
`run_sensors`: `iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget`
scrolls a widget and asserts `UiRenderState::resolved_region` (the
query `run_sensors`'s hit-test and `window_region` both now go through,
per §2b) reports the moved, not the pre-scroll, position — within
0.01px of the exact expected delta. `run_sensors` itself needs a
`HasEvents`/window/cursor-state harness this pass did not build; the
coverage that matters (does the position query the router uses reflect
the move) is exercised directly instead.
5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll`
both before and after scrolling it (`iris/run-headless.sh` against a
small purpose-built example, or an addition to `tabs`), and diff the
two frames: the clipped edge of the content must have moved with the
scroll while the viewport's own border (drawn by `Masked`, not moved)
stays put — the specific case worked through in §2b. A mask rectangle
that stayed at its pre-scroll position while its content slid past it
is the regression this checks for, and it is visible in a single
screenshot, not just in a counter.
**Result (2026-09-04): pass, checked numerically rather than by
screenshot.** No example in this repository builds a `Masked`-wrapped
`Scroll` (`tabs`'s "text edit scroll" tab uses `TextEdit`'s own internal
scrolling, not this widget), so there was nothing to screenshot without
first authoring a new example. Checked instead in
`iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves`,
on the exact data the fragment shader's `resolve_move` reads: the
masked widget's own `move_offsets` slot delta is `[0, 0]` both before
and after scrolling its content, because `Masked` is never itself the
target of a move — only its child is, on a separate, deeper slot in the
chain (§2b's "scroll-container case, checked rather than assumed"). A
pixel-level screenshot check of this remains open; see RUST.md's next
step.
6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`**
stay clean at the defaults (iris has no tests today per I0b, so this is
presently only clippy/fmt; add the first real widget-layer tests here if
the move-offset chain or `draw_twice` are non-trivial enough to want
one, per "match the codebase's testing posture" — judge that once the
code exists rather than pre-committing to a number of tests here).
**Result (2026-09-04): pass.** `cargo fmt --all -- --check`,
`cargo build --workspace --all-targets`, and `cargo clippy --all-targets`
are all clean (one pre-existing, unrelated warning about `naga`/`wgpu`/
`winit` future-incompatibility, from dependencies, not this change).
`cargo test --workspace`: the 14 pre-existing `TextEdit` tests plus 4 new
ones in `iris/src/layout_tests.rs` (conditions 25 above), 18 passed, 0
failed — the move-offset chain turned out non-trivial enough (three real
bugs found only by writing it) to clearly clear the "match the testing
posture" bar this section left open.
### 9. Rejected, and why
- **A flat (non-chained) per-subtree offset table**, Iris's literal
phrasing — rejected in §2 for breaking under nested independent moves
(a swiped row inside a scrolling list). Costs nothing extra to avoid: the
chain is the same mechanism with one more field.
- **Keeping `region_mut` recursion as the only move mechanism** — rejected
as the steady-state path (O(primitives in subtree), exactly what a
transcript scroll must not pay every frame) but kept for resize-shaped
changes (§3) where the content's own region field, not an ancestor
chain, is what has to change.
- **A second, size-only trait method kept alongside `draw`** (e.g.
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
implement to skip a draw when a cheap answer exists) — considered and
rejected: it reintroduces exactly the "two names for one concept" split
this change removes, for a saving `is_size_independent` (§1, §3b)
already covers for the cases where it would actually help (fixed-size
leaves). A widget whose size is cheap to compute but whose *drawing* is
not (unlikely in this codebase's widget set, but conceivable) is better
served by that widget caching its own draw output internally — exactly
the pattern `TextView::render` already uses (§4) — than by a second
trait method every implementor has to reason about.
- **Passing `available` as an explicit parameter to `draw`** (mirroring
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
which already carries the same information into every widget that needs
it; adding a parameter would just be a second route to a value already
reachable, and would invite the two drifting apart.
- **Eagerly propagating a moved widget's delta into every descendant's own
offset value** (rather than chaining and resolving in the shader) —
rejected as O(descendant widgets), which is smaller than O(primitives)
but still not O(1), and the shader-side chain costs nothing extra to get
the better bound.
## Deviations found during implementation (2026-09-04)
Five corrections this file's first draft did not anticipate, each found by
`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical
pre-change screenshot (pass condition 1) and traced with `eprintln!` in
`draw_inner`/`reposition` — not by reasoning about the design in the
abstract. Recorded here rather than silently fixed in place, per the code
rules' escape-hatch requirement.
1. **`Aligned`'s provisional draw must call `painter.widget`, not
`widget_within(&self.inner, painter.region())`.** §6's original text drew
the sample as the latter. `widget_within` composes its `region` argument
as *local*, `UiRegion::FULL`-relative coordinates against
`painter.region()` (exactly what `UiRegion::FULL.within(&self.region) ==
self.region` relies on); handing it `painter.region()` itself —
already-resolved, window-relative coordinates — composes that frame a
second time. For the root widget this is silently the identity (its
region already is `[0,1]`), which is why it can look correct in a
trivial case and only breaks once something is nested — i.e. always, in
practice. Symptom: a centered child rendered at a wildly wrong offset
nested more than one level deep. Fixed by using `painter.widget`, which
hands the child `self.region` unmodified, with no second composition.
2. **A widget that reports a size smaller than its offered region must
actually paint at that size, anchored top-left of what it was given —
not fill the full offered region while merely *reporting* a smaller
number.** `Sized` and `MaxSize` both had exactly this bug: their
`desired_width`/`desired_height` predecessors capped the *reported*
value but their `draw` bodies called `painter.widget(&self.inner)`
unconstrained, which was harmless under the old two-pass model (a parent
always queried the size *before* drawing, so by the time `draw` ran the
offered region already matched) but wrong under `Aligned`'s new
provisional-draw-then-reposition pattern, which offers the *whole*
region on the first, learning pass. Symptom: a `.sized((100, 100))` rect
rendered stretched to fill its whole row instead of a 100×100 square.
Fixed by having both widgets carve the declared sub-region (`UiSpan`
sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever
they were offered before drawing the child in it. `Image` needed the
same treatment from the start (`texture_within` at its own natural size,
not `texture()` at the full offered region) and was written that way in
the first pass, once this was understood; `Rect`'s "fill whatever I'm
given" is the one case where painting the *whole* offered region really
is the declared behavior, so it needed no change.
3. **The move-offset chain's `parent` link cannot be found by looking up
the parent's `ActiveData` in `draw_inner`, because the parent's
`ActiveData` does not exist yet while its own `Widget::draw` is still
running.** `ActiveData` is inserted only after `draw` returns
(`render_state.rs`, end of `draw_inner`), so a child drawn partway
through its parent's `draw` body — the ordinary case, since every
composite widget draws its children from inside its own `draw` — would
always read "no parent" from `self.active`, silently orphaning it at the
root of the chain. Fixed by threading the parent's `move_slot` down
through `Painter` (it already carries `mask`/`layer` the same way) and
passing it explicitly into `draw_inner` as `parent_move_slot`, rather
than deriving it from `self.active.get(parent_id)`. `move_parent_of`
(the `self.active`-based lookup) is kept, but only for `redraw()`, whose
target's parent genuinely is already active at that call site — the
doc comment on it says which is which. Symptom: `reposition` computed
the right delta and wrote it to the right slot, but the shader never
saw it, because the primitive doing the actual painting chained to
`u32::MAX` one level too early.
4. **`Painter::reposition` cannot reuse `active.region` as "where the
widget currently is," because for a widget offered more room than it
used, `active.region` is the *offered* box, not the *painted* one.**
This only matters for `reposition` (used by `Aligned`); `mov` (used by
`draw_inner`'s own same-size-different-position dispatch, for `Scroll`
and `Offset`) has no such gap, because there the offered region *is*
the visual footprint — content is sized to fill exactly what it is
given. `reposition` instead reconstructs "from" as `active.size`
(already tracked, per §5) anchored at `AxisAlign::Neg` within
`active.region` — i.e. it assumes the child painted itself top-left of
whatever it was offered, per point 2's convention — and **overwrites**
the slot's delta rather than accumulating it the way `mov` does, since
"from" is recomputed fresh from stable inputs every call and repeating
the same `reposition` (an unrelated redraw elsewhere re-running this
widget's parent) must not drift further each time. The one shape this
does not cover: `Aligned` wrapping `Aligned`, where the inner one's own
`reposition` may have moved its content away from top-left already. No
widget or example in this codebase builds that today; if one needs to,
`reposition` would need the child to report *where* it painted, not
just how big, which is a larger change than this pass's scope.
5. **A widget's `move_offsets` slot is allocated once, on its first-ever
draw, and reused in place — never reallocated — for every later redraw
of the same id, with its delta reset to `[0, 0]` on each reuse.** Not
spelled out in §2's original text, which only said slots are assigned
"when the widget is first drawn." Reallocating a fresh slot on every
redraw would leave any *retained* (not-redrawn) descendant's `parent`
link pointing at a now-orphaned old slot — a permanent leak, and worse,
a descendant that silently stops tracking its ancestor's future moves.
Resetting the delta on reuse (rather than carrying it forward) is
required because a full redraw bakes the widget's correct absolute
position into the fresh `region` argument directly; a stale delta left
over from before the redraw would double-offset it.
Two further points worth recording because they were *design decisions*
made while implementing, not bugs — `LAYOUT.md`'s own text left them
unspecified rather than getting them wrong:
- **`Scroll` offers its content a region sized by the *previous* frame's
measured content length, not a fresh one.** A fresh measurement would
require drawing the content once to learn its size and — since that
provisional size essentially never matches the previously active one —
redrawing it a second time at the real size, on every single scroll
tick, which is exactly the cost §2 exists to remove. Using the stale
length means an ordinary scroll (position changes, content does not)
offers the same *size* as last frame, only shifted, which is what makes
`draw_inner` dispatch it as the O(1) move. The cost: a real content-size
change lags one frame before the container's scroll range reflects it,
self-correcting the frame after (the content length itself, read from
what was actually drawn, is never stale — only the offered *region* used
for placement is). No example in this repository builds a `Scroll` yet,
so this could not be checked against a pixel diff; it is covered instead
by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which
build a tree and drive `UiRenderState` directly with no GPU or window
needed.
- **`redraw()`'s parent-relayout check draws the widget first, then
compares the fresh `ActiveData.size` the draw produced against the size
from before removal** — the mirror image of the old code's "query size,
compare, decide whether to draw," which no longer has a size query to
do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx`
along with `desired_width`/`desired_height`). This can occasionally draw
a widget once more than the old code would have (if the parent it
bubbles up to ends up redrawing the same widget again as part of its own
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
redundancy for free, and this path is not one of §8's measured
conditions, so the remaining slack was accepted rather than chased
further.
## For IRIS.md
When this lands, copy this entry into `IRIS.md` (newest first):
> **2026-09-04 — `Widget::draw` reports the size it used; `desired_width`/
> `desired_height` are gone.** A widget used to implement three methods
> (`draw`, `desired_width`, `desired_height`); it now implements one,
> `fn draw(&mut self, painter: &mut Painter) -> Size`, which draws into
> `painter.region()` and returns how much of it was used. Why: the two
> extra methods routinely re-simulated what `draw` was about to do anyway
> (`Span::desired_ortho` copied its own draw loop to get cross-axis sizing
> right) — one visit per widget per frame instead of up to three. A
> container that needs a child's size before placing it (alignment,
> centering) draws the child once at a provisional region, reads the
> returned `Size`, and calls the new `Painter::reposition` to move it into
> its final spot — an O(1) offset write, not a second draw. A widget whose
> drawn output never depends on the size it's given (a fixed-size `Rect`,
> a decoded `Image`) overrides the new `fn is_size_independent(&self) ->
> bool { false }` to `true`, which skips redrawing it when only its
> offered region changes shape.
>
> ```rust
> // before
> fn draw(&mut self, painter: &mut Painter) { /* ... */ }
> fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
> fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
>
> // after
> fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
> ```
>
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
> design, the move-offset mechanism this shipped alongside, and the file
> list.
+983
View File
@@ -0,0 +1,983 @@
# ai-app — plan
A phone interface to AI coding sessions — Claude Code and llama.cpp — built
to replace the Claude app for day-to-day use. Two motivations: local models
need a front end at all, and owning the client means fixing what the official
app gets wrong (it won't deliver a typed message until the turn fully
finishes, where the TUI injects it at the next tool boundary).
Same shape as `../dev-updater`: a Rust (Axum) backend on the desktop, a
Kotlin/Compose Android app, pinned self-signed TLS between them.
This file records decisions with their date, their rationale, and what was
rejected. Update it in place when one changes; `AGENTS.md` is the working
notes layer and must not become a second version of it.
## The one idea everything hangs off
**A session is a child process, translated into one common event model.**
The backend spawns it, translates its dialect into a common event stream,
and keeps an append-only transcript. A new session type is a new driver —
never a session-type branch in shared code (routes, transcript, app
screens). SSH falls out of the same shape: a remote session is the identical
command wrapped in `ssh host …`, and the driver never learns which it got.
```
Android app (Compose)
│ HTTPS (pinned CA) — REST for actions, SSE for live events
backend (Rust/Axum, desktop)
├─ SessionManager ── Session ── Driver (trait)
│ ├─ ClaudeDriver (claude stream-json over stdio)
│ ├─ LlamaDriver (llama-server over HTTP)
│ └─ EchoDriver (the test rig)
│ each driver's process is spawned through a Transport,
│ locally or as `ssh host …`, decided by the setup it names
├─ usage.rs (Anthropic OAuth usage endpoint, per machine)
├─ models.rs (HuggingFace browsing and GGUF downloads)
├─ files.rs (the file explorer's half of the backend)
└─ config.ron + per-session transcript files
```
## Architecture
### Setups and providers (2026-08-28)
**A setup is a machine, and it carries the providers that machine has.**
Optional ssh details, plus the list of what can be run there. Spawning is
two choices in order: pick a setup, then pick one of its providers.
This replaced an independent providers × hosts cross-product, because the
two axes are not independent: a provider is only real on a machine where
that CLI is installed, so the cross-product offered combinations that cannot
work — `claude-cli` on a machine with no `claude`, and every provider paired
with a host the driver ignores (`EchoDriver` takes no host, so "Run on" was
a control that silently did nothing).
- **Echo is seeded, not implicit.** It lives in the setup with no ssh,
because it runs in-process and has no transport to cross. It is written
into `config.ron` on first run rather than conjured at read time — a
provider nobody can see in the file is one nobody can edit from the phone.
- **Providers are discovered by asking the machine**, never typed, so an
enrolled token cannot introduce a command. The escape hatch for a binary
somewhere unusual is editing `config.ron`, deliberately the one authority
the phone does not have.
- **Migration code is deleted once the update carrying it is received.** The
providers/hosts migration ran on the one host there is and is gone. A file
in the old shape now fails to parse, which is correct because no such file
exists.
### Backend layout (`server/`)
axum 0.8, axum-server + rustls, tokio, serde, clap, tracing. Rust edition
2024, warning-clean, clippy clean.
- `main.rs` — bootstrap, TLS listener, auth layer, enrollment, wg0 binding.
- `routes.rs` — the whole HTTP table in its module doc comment. **That
comment is the surface's source of truth**; this file does not repeat it.
- `auth.rs` — the bearer-token middleware.
- `config.rs` — the persisted schema.
- `setups.rs` — machines and provider discovery.
- `files.rs` — the file explorer (`EXPLORER.md`).
- `usage.rs` — Anthropic usage polling, per machine.
- `models.rs` — HuggingFace browsing and GGUF downloads.
- `media.rs` — the image media-type/extension table, shared by the four
places that must agree: storing an upload, serving it back, handing one to
a driver, and saving one a tool produced.
- `session/mod.rs``SessionManager`, the live registry; every mutation
funnels through it so in-memory and on-disk state cannot come apart.
- `session/driver.rs` — the `Driver` trait and the common event model.
- `session/claude.rs`, `session/llama.rs`, `session/echo.rs` — the drivers.
- `session/transcript.rs` — the append-only JSONL event log per session,
with monotonically increasing sequence numbers (the phone's resume cursor).
- `session/transport.rs`, `ssh.rs` — running a driver's command locally or
over ssh.
- `session/process.rs` — the pid + start-time record that lets a process
outlive the backend.
- `session/import.rs` — continuing a Claude Code session the machine has.
- `session/pending.rs` — operations in flight on importable sessions.
The certificates, enrollment, wg0 binding, owner-only file modes and RON
house rules live in the `wg-app-link` submodule, shared with dev-updater.
### The common event model
Driver output, whatever the dialect, is normalized into one enum before it
touches the transcript or the phone. Every event is appended to the session's
transcript with a sequence number, then fanned out to SSE subscribers. The
phone renders purely from this stream: reconnecting is "give me events after
seq N", so there is no separate history path to drift from the live one.
- `UserMessage { text }` — echoed into the transcript **by the manager, not
by drivers**, so every device renders the conversation from one stream.
- `AssistantText { delta }` — streaming text, rendered as markdown.
- `ToolStart / ToolUpdate / ToolEnd { tool, input, output }`.
- `Image { ref }` — saved under the session dir, fetched by URL.
- `Question { id, prompt, options }` — anything needing a human. Claude's
AskUserQuestion and permission requests (canUseTool) are the same shape;
a permission is a question with two bare options, not a different kind.
- `Answered { id, answer }` — so a question card resolves on every connected
device, not just the one that answered.
- `Status { state }` — idle / running / awaiting-input / compacting / exited.
- `UsageDelta { tokens, context }` — what a turn cost and how much the model
was holding when it ended. `context` is prompt plus both cache figures,
taken from the **last assistant message** rather than the turn's `result`:
measured 2026-08-30 against CLI 2.1.237, the result adds a turn's messages
up, so its cache read of 40,211 was the same conversation counted twice.
It is carried rather than summed, because it goes *down* — a compaction
replaces it and a clear leaves it unmeasured. `driver::context_after` is
that rule and the phone folds with the same one. A session the server has
no measurement of asks the CLI's own file instead of waiting for a turn
(`import::context_of`).
- `MessageQueued` / `MessageDropped` — see "Taking a queued message back".
- `PeerMessage` — see "A message from another agent".
- `Error { message }`.
Inbound, the `Driver` trait is small: send a message, answer a question,
interrupt, set the model, compact, unqueue, and two ways out — `detach` (the
server is going away and means to come back) and `stop` (the session is being
deleted, so the process must not survive). Every driver owes exactly one of
the two.
`send_user_message` during a run is the point of the whole app: the dialect
queues it for injection at the next tool boundary rather than the end of the
turn.
### Claude driver specifics
Spawn: `claude -p --verbose --input-format stream-json --output-format
stream-json --permission-mode <mode>` in the chosen working directory, plus
`--model` and, where one has been chosen, `--effort`. Wire-format notes are
pinned against CLI 2.1.237 in `session/claude.rs`'s module doc: permissions
need the hidden `--permission-prompt-tool stdio` flag, AskUserQuestion answers
ride `updatedInput.answers` keyed by question text, and `set_model`/`interrupt`
are control requests.
**The thinking level is settled at launch** (added 2026-09-04, because it is
the largest saving available on a long session: output is about an eighth of
what a session costs and thinking is the bulk of output, against the ~1.5% that
is prose). The CLI's only two setting control requests are `set_model` and
`set_permission_mode` -- checked against the 2.1.258 binary -- so there is no
way to ask a running process to think differently. `set_session_effort` is
therefore shaped like `set_session_cwd` rather than like `set_session_model`:
it records the level and **stops the process**, and the next message or Start
launches one that has it. It lives in the session settings dialog beside the
working directory for that reason, not on the session bar beside the model and
the mode, which do take effect mid-turn. `None` is a level in its own right --
the CLI's own default -- so the picker can return to it; a level this app named
as the default instead would be this app choosing one.
**What a new session starts at is `Config::default_effort`**, applied in
`spawn_session` rather than filled in by the spawn screen, so it holds for an
import and a bare API call as well. It is set by the spawn screen's own
picker, whose label says so: one control, where new sessions are made, rather
than a settings page for a single value. It is not on a provider, because
providers are discovered and the next rediscovery would erase it, and not on
the phone, because a second device would then spawn at a level nobody there
chose. `GET`/`POST /defaults` carry it, as a struct rather than a bare value
so the permission mode -- still hardcoded to `auto` on the spawn screen -- can
move there without a second route.
**`--resume` only ever runs when nothing else has that session open.** That
is the rule behind the import refusal, the single `ClaudeDriver::launch`
entry point, and the `Exited` correction below; two CLIs on one session file
duplicate the conversation into it and bill the second for re-reading it all.
### The llama driver
One `llama-server` per session, started through the same `Transport` as any
other process and then reached over HTTP on a loopback port. Two things are
deliberate and easy to undo by accident:
- **The conversation is rebuilt from the transcript**, not kept in the
driver. A copy in driver memory is invisible to a second device and gone
when the process restarts. That leaves the Claude driver as the odd one
out rather than this one — the CLI's memory is a cache in front of the same
transcript. Resolve any inconsistency in this direction.
- **A llama session runs on whatever machine its setup names** (2026-09-04,
the last of phase 5). A transport is "run this" plus "reach this port", and
the second half is `Transport::reserve_port` — the port the server binds
*there* and the port that reaches it *here*, the same number locally —
carried by `Launch::reaching` onto the connection that already runs the
command. `llama-server` binds loopback on the far machine, so nothing is
served to its network. The far port is a guess from a range below the
ephemeral one, because no portable way to ask a machine for a free port
avoids racing the bind anyway; a collision is not silent, since the server
fails to bind and the readiness poll reports what its log said.
- **The model file lives on the machine that serves it** (2026-09-04). Each
setup names its own models directory (`SshConfig::models_dir`, default
`~/.local/share/ai-app/models` expanded *there*), and a spawn resolves the
key on that machine — one round trip answering "at /abs/path" or "missing",
so a model that is not there is refused at the spawn rather than becoming a
server that never becomes ready. The spawn screen offers
`GET /setups/{id}/models`, that machine's list, rather than `GET /models`,
which is this backend's downloads. Downloading *to* another machine is
deliberately not built: a multi-gigabyte transfer with no progress
anywhere, and the file gets there however anything else on that machine
did.
- **The readiness poll watches the process, not only the port.** A model that
will not load, a port already taken, a flag an older build does not know:
all exit within a second and none will ever answer `/health`, so waiting
out the 300s timeout turned the server's own account of the problem into
"gave up". The failure carries the tail of `llama-server.log`, which on a
remote session is the only copy anybody reading the phone can see.
### Models (2026-08-28)
- **A download belongs to the model, not to the request.** Keyed by
`owner/repo/file.gguf` and owned by the server, so a second device can
watch one it did not start and an hour-long fetch survives a locked screen.
Every run has an id and its outcome outlives it, because "not downloading"
otherwise means finished, never started, or someone else's run ended while
you were away.
- **Progress is measured**, never estimated: `total` is Content-Length, or
Content-Range's last field on a resume, and absent when the server says
nothing.
- **Resume is guarded by identity, not by hope.** A partial carries the ETag
it was written against and a mismatch discards it. `If-Range` would be the
tidy mechanism but HuggingFace's CDN ignores it (probed 2026-08-28). The
published sha256 is checked before the file is renamed.
- Sampling parameters reach a driver as an untyped `params` map, so the
shared schema does not grow llama.cpp's vocabulary.
### Transport (ssh)
- A remote session is a local one with the command wrapped in `ssh -T host …`,
every argument shell-quoted, run with `exec` so dropping the connection
takes the CLI down rather than orphaning it. Key-based auth only, through
the system `ssh` client, which inherits `~/.ssh/config`, agents and jump
hosts for free.
- **The transport wraps the driver, not the other way round** (2026-08-28).
A driver says what to run; something above it turns that into a process.
Otherwise transport knowledge sits inside a translator whose job is a wire
format, and every future driver has to remember to do the same.
- **A forwarded launch gets a pty and every other one does not** (measured
2026-09-04). Killing the ssh client ends a CLI because it closes the stdin
that CLI is reading; `llama-server` never reads its stdin, so the same kill
left it running on the far machine with the model loaded — one orphan per
stopped session. With `-tt` the far side takes SIGHUP when the connection
goes. Its log then arrives through a line discipline, which nothing parses.
`-T` stays everywhere else, where a pty would rewrite the JSONL.
- **`command -v` follows ssh's non-login PATH**, which is narrower than an
interactive shell's, so a binary somewhere unusual is invisible to
discovery. Point `command` at an absolute path.
- **Images need no file transfer.** `attachment_block` base64s an upload into
the stream-json message, and produced images come back the same way.
- **Any other file is told to the session by path** (2026-09-03): a trace, a
log, a zip — things a model cannot be shown and the CLI can read. The
upload is streamed to disk under the session's attachments on this machine,
and the message ends with `Attached file: /abs/path`. For a session on
another machine the upload also copies the file there in the same request,
over one `ssh` invocation, landing in the setup's `attachmentsDir` if set,
else the session's cwd, else the login home. The resolved remote path is
recorded beside the file (`<name>.remote`) and is what the driver names. A
copy that fails fails the upload, so no message ever names a file that is
not there.
### Moving a session to another directory (2026-08-31)
`POST /sessions/{id}/cwd`, from the session settings dialog. A working
directory is settled when the process is spawned, so this records the new one
and **ends** the process in the old one. It does not start a replacement: a
session with no process starts on the next thing said to it or on Start,
which is this app's rule everywhere else.
The path is checked against the session's own machine and **refused** if it
is not there, rather than corrected. The spawn path corrects instead, because
it is resuming a directory the *machine* recorded, which can be gone through
nobody's fault; a path somebody has just typed is different, and a mistyped
one accepted here surfaces much later as a session that will not start.
**Nothing of Claude Code's own is moved.** Measured against CLI 2.1.237:
`claude --resume <id>` finds a session from any working directory. Relocating
the file would mean reproducing a rule this app cannot see the whole of — the
project directory is the path with every non-alphanumeric character replaced
by `-`, truncated at 200 characters with a hash appended, and overridable.
### A message from another agent (measured 2026-08-31)
Measured by sending a real cross-session message to a real stream-json
session on CLI 2.1.237: the CLI emits **no `user` record** for it, and
nothing in the partial-message stream mentions it. The whole of it arrives as
an `origin` object on the turn's `result`, in the same shape the session file
records — so `import::peer_message` reads both and there is one function for
one wire format. Only peer-caused turns carry it.
**The cost is the position, and it is paid on the wire rather than on
screen.** The event cannot be recorded in place: at no earlier point does the
CLI say why the turn started, and the transcript is append-only, so by the
time anyone knows, everything the message caused is already written above it.
Tailing the CLI's own session file instead was rejected and stays rejected —
two sources of truth for one conversation and a poll per live session.
So `PeerMessage` carries a `turnStart`: the seq of the `Status` that opened
the turn, stamped by the pump, which is the only thing that knows a seq and
sees every driver's turns. The phone draws the note at that seq. A status
draws no row, so there is nothing to collide with and the list stays sorted,
which is what the scroll anchor and paging depend on. `turnStart` is absent
where there is nothing to correct — a message replayed by `import` is already
in the right place. The echo driver models both shapes: `/peer` and
`/peer-turn`.
### Taking a queued message back (2026-08-31)
`POST /sessions/{id}/unqueue`, answered by `Driver::unqueue` and recorded as
`Event::MessageDropped` so every device loses the bubble and a reconnect does
not replay it.
The answer has **three** states rather than a yes/no, and that is the whole
design: `Dropped`, `AlreadySent`, and `Unknown`. The Claude driver can only
ever give the middle one — it writes a steer into stdin the instant it
arrives, which is what makes a steer reach the model at the next tool
boundary instead of the end of the turn. What waits in `awaiting` is the
*announcement*, not the message. Holding the write until a boundary would
make the drop real everywhere but costs a steer one model call, which is the
latency the immediate write removed. So the refusal is the honest answer, and
it is reported on the bubble the reader pressed rather than in the screen's
error row a screen away.
`Unknown` is not "we could not find out": a driver that is gone reported
everything it was holding when it closed.
### Session processes outlive the backend (2026-08-29)
A session's process is **left running when the backend stops and adopted
again when it starts.** A rebuild, a service restart or a crash must not end
a turn somebody is waiting on, and a turn can be minutes long. What this
replaced leaked processes either way: `shutdown_all` asked every driver to
stop and then exited immediately, with the SIGKILL escape hatch on a timer
inside the dying runtime, and whatever survived was orphaned with nothing
written down to find it by.
Inside the session directory, beside the transcript:
- `process.json` — the pid, the kernel's **start time** for that pid, and how
much of the output log has been read. The start time is what makes the pid
an identity: pids are reused, and adopting a stranger's would mean never
resuming the real conversation and signalling something unrelated.
- `stdin.fifo` — opened **read-write** and inherited by the process, so it is
its own last writer and never reads EOF when the server goes away. Closing
stdin therefore stops being the graceful-exit signal; ending a process is a
signal, and only `Driver::stop` sends one.
- `stdout.log` / `stderr.log` — plain appended files, read from a byte
offset. A fifo would fill its 64 KB buffer and block the process while
nothing drained it, stalling the very turn this exists to protect.
**Remote sessions are adopted too, and the recorded pid is the `ssh`
client's** — the process the backend owns, which lives exactly as long as the
remote command does. The far `claude` always has an sshd pipe on stdin
whichever version started it, since the fifo is on the backend's side, so a
remote session's stdin says nothing about which server started it.
**A zombie is dead.** `/proc/<pid>/stat` keeps the entry, with the same pid
and start time, until the exit status is collected — so a finished process
answered "still there" for as long as nothing reaped it, and `Alive` is the
word that makes `Exited` unsayable. `process::stat_of` reads the state field
alongside the start time.
### Stopping and starting a session's process (2026-08-30)
`POST /sessions/{id}/stop` and `/start`: end the process without ending the
session, and start it again on the same conversation. Three decisions worth
not undoing:
- **Stop signals the recorded process and says nothing else.** It does not go
through the driver and does not announce `Exited`. The record is the
session's rather than any dialect's, so this works for a session whose
driver is in no state to be asked, and the driver's own reader already
reports the death correctly. Announcing it here would be a guess arriving
ahead of the measurement, and wrong for the grace period.
- **Start replaces the driver and nothing else.** The transcript, the event
pump and every open SSE stream stay where they were, so starting again is
not a reconnect for anybody watching, and there is still exactly one writer
of the transcript. `LiveSession` and `Commands` share one
`Mutex<Arc<dyn Driver>>` rather than each holding a copy.
- **Start is refused unless the session is *known* to have exited.**
`Unknown` means nobody could find out, and starting on that is exactly the
two-CLIs-on-one-conversation fault `session::process` exists to prevent.
**`Exited` is a claim about a process, and the record is what settles it.**
It is the one status that draws the phone's Start button and lets
`start_session` build a driver, so it is checked against `session::process`
before it is believed (`corrected`, called in `launch` and `start_session`).
A record not known to be dead makes it false and the session reports
`Unknown` instead. Every other status is left alone — those are the pump's,
written from what the process itself said. Without this, a session adopted at
a backend start kept the transcript's `Exited` while its CLI ran, Start was
accepted every press, and each press attached *another* reader to one
process: one reply drawn interleaved several times over
(`GotGotGot it — it — it —`). **A driver that `start_session` replaces gets
`Driver::detach`**, because swapping the `Arc` does not end the tasks the old
one is running.
**Who says so matters as much as what is said.** A status written into the
manager's view alone is two screens disagreeing — the list reads the
manager's status and the session screen replays the transcript, which showed
up as a stop button turning into a play button a moment after the screen
opened. So **a driver announces the state it starts in, through the event
sink.** It says `Idle` only when it *started* a process; adopting says
nothing, because a process already running may be mid-turn and the
transcript's last word is the better answer until its output says otherwise.
**A message or a command starts the process if there isn't one.** Refusing
was work handed back: read the status word, find the other button, press it,
type the thing again. `--resume` puts the new process on the same
conversation, so nothing about what was typed changes. A rename is included
for a sharper reason: Claude Code keeps its own copy of the name, that copy
is what its session picker and other agents' session lists show, and a
session is only ever *given* a name at birth since every later start is a
`--resume` — so a rename reaching no process would leave the two lists
disagreeing permanently. Its save happens before the telling, so a failure
there says the telling failed rather than the rename.
`start_if_exited` is one function under one write lock, which is what stops
two requests arriving together from starting two CLIs. Its callers want
opposite answers: "there is already a process" is a refusal worth showing to
somebody who pressed Start, and nothing at all to a message. Only `Exited`
starts anything — `Unknown` has a process that may well be reading its fifo.
`run_command` judges against what `start_if_exited` returned rather than
re-reading a status the pump may not have caught up with.
On the phone this is one button in the composer, left of Send, whose mark and
colour say what pressing it would do now: an orange pause while a turn runs
(interrupt — the process stays), a red stop when it is not (end the process),
a green play when it has exited. One button rather than three that come and
go, so its presence is never the signal. It is disabled while its own request
is in flight, as a courtesy; the server refuses the second request either way.
### A backend start adopts, and starts nothing (2026-08-30)
`SessionManager::new` takes charge of the processes still running and
**leaves every other session exactly as it found it** — listed, with its
transcript, its pump and its SSE stream, and no driver until somebody asks
for one. It used to launch a driver for every session in the config, and
`ClaudeDriver::launch` starts a process when there is none to adopt, so a
session somebody had deliberately stopped came back at the next rebuild, and
the `Idle` the new driver announced stamped it as active at the moment of the
restart. On the phone that read as *every* session idle and "just now", with
the list sorted by that time in an order that meant nothing.
- **`Launching` is the parameter that says which it is**, and an import's
seed rides on the asked-for variant, because a restart re-seeding a
transcript would write the imported conversation into it twice.
- **A session with no process has no driver.** `DriverCell` is an option
rather than a driver whose requests go nowhere, so "nothing is running
this" is a state the code can be asked about instead of one it discovers by
sending into a dead fifo. `LiveSession::ask` answers it with an
`Event::Error` naming what could not happen — a request nobody can carry
out is reported, never swallowed.
- **A launch never moves a session's clock.** A status a launch has to
correct is written at the time of the last thing the session actually did,
not at `now()`. Taking charge of nothing, every word but `Exited` is
disproved at once — a backend killed mid-turn leaves a transcript saying
`Running`, which draws a stop button for a turn that ended hours ago — but
stamping the correction with `now` is the same lie in the same field that
`Transcript::last_activity` exists to prevent.
- **A session that has never done anything reports when it was created.** Its
transcript is empty, since a driver announcing the state it starts in is
not news, so it is the one session with no line to read a time off. Not the
file's mtime, which is a worse answer for a checkout that can be copied;
`SessionConfig::created` is recorded rather than inferred.
### Sessions spawned while testing clean themselves up (2026-08-30)
`--throwaway-sessions`, **on by default in a debug build**. Every session
such a server spawns is marked `throwaway` in the config, and a marked
session's process is stopped when the server exits or is signalled.
Leaving processes running is right for the sessions somebody is using and
exactly wrong for the ones a test made: those leave a `claude` behind that
every later server adopts, they cost tokens if anything speaks to them, and
nothing says they are there — twelve accumulated on this machine in a day.
- **The flag marks; the mark decides.** What a server was told at startup
governs only the sessions it spawns, and the mark is written into the
session, so it outlives that server. A session spawned deliberately keeps
running whichever server is up when one exits, and a throwaway one is
cleaned away even by a server started without the flag. The alternative —
the exiting server stopping whatever it has marked in memory — makes
cleanup depend on which process is up.
- **Stopping is not asking.** `process::stop` leaves its SIGKILL on a tokio
timer, which a shutting-down runtime never runs; that is precisely how the
original `shutdown_all` leaked. The exit path waits with
`process::wait_gone` — one deadline for all of them, since they were
signalled together — and kills whatever is left.
### Importing refuses a session that is already open (2026-08-29)
Claude Code keeps a descriptor per live session at
`~/.claude/sessions/<pid>.json` carrying the `sessionId` and a `procStart`
the same pid-plus-start-time identity used above. So "is this session open
right now" is a **measurement**, and the import list reports it as `no` /
`yes` / `unknown`. Three answers because a machine that keeps no such record
cannot answer, and "could not check" is not "nobody is using it".
`yes` is refused. On 2026-08-29 an agent imported the session it was itself
running in: two `claude --resume` processes on one file, the whole 65 MB
conversation with 154 embedded screenshots duplicated into it under a new
prompt id, and the adopted copy billed for re-reading all of it. It ended at
the account's session limit.
**Importing and deleting run on the server, and a batch is handed over in one
call.** `POST /setups/{id}/importable/{delete,import}` each take a list of
ids, answer 202, and do the work in spawned tasks — the phone that asked is
free to leave, and used to cancel its own batch by doing so. A list rather
than a route per session because one request per row made a handover only as
atomic as the network, and a row nobody asked for looks exactly like a row
nobody picked. Only the *registering* is atomic; the work settles per row,
since six deletes that all roll back together is not something a filesystem
offers.
What replaces the reply is `session::pending`: every row carries `pending`
and `error`, and `/importable/events` streams the changes. **Both, not
either** — the stream is a broadcast with no memory, so an operation that
starts and finishes while it is still connecting is one nothing will ever be
said about, which left a row marked "waiting" for ever. A single tap still
waits, because "continue this and take me to it" needs the session it made
and 202 does not carry one; the batch and the tap share `spawn` so the two
cannot drift about what importing means.
An imported session **keeps itself level with the CLI's file**, so work done
at a terminal appears without anyone pressing anything. Which lines came from
*here* is answered by counting the events this session has recorded, **not**
by looking at its status — a turn that starts and finishes between two polls
reads as idle at both, and its own output gets replayed on top of itself.
That bug was visible on screen as `donedone`.
### Usage limits (Claude)
Poll `https://api.anthropic.com/api/oauth/usage` — the endpoint behind Claude
Code's `/usage` — with the OAuth token from `~/.claude/.credentials.json`,
headers `anthropic-beta: oauth-2025-04-20` and `User-Agent:
claude-code/<version>` (without the User-Agent it lands in an aggressively
rate-limited bucket). Poll at ≥180 s, only while a Claude session exists or
the usage screen is open, and cache the last answer. It is undocumented, so
`usage.rs` treats every field as optional and degrades rather than erroring.
**Per provider, not per machine (2026-09-04).** A machine is not what is
metered; the provider a session runs is. One machine offers echo, the Claude
CLI and a local model side by side, and only the second spends anything — so
pairing a session with a snapshot by machine alone drew the CLI's five-hour
window under every echo session on it, a quota that session cannot spend. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too, so the
two lists cannot disagree) and `GET /usage` is matched on machine *and*
provider. `None` is a session that meters nothing, and the phone draws
nothing at all for it — not a zero, and not "unknown".
`DriverKind::Echo` names a meter of its own that exists only when a test has
asked for one: `/usage` in an echo session sets an invented answer
(`usage::Fixture`), and with none set there is no snapshot and no bar. That
is what makes those screens' states reachable — a number near the top, a
window between blocks with no reset time, a machine nobody logged into, one
that could not be reached — without spending real quota to arrange them,
which is why none of them had ever been looked at.
**Per machine, not per backend (2026-08-29).** The credential store that
matters is the one on the machine the session runs on, because that is the
account being billed — and in the layout this aims at, `ai-server` is on the
host, the host has no `claude`, and the CLI machine is a remote. So
credentials are read through the session `Transport` (`$HOME` expanded by the
far shell, because a path built locally is the wrong home), one snapshot per
setup that offers Claude. The HTTP call stays on the backend, so the far end
needs nothing but a shell.
The snapshot says which of four things happened rather than carrying a flag
and a message: `ok`, `notLoggedIn`, `unreachable`, `failed`. `notLoggedIn` is
the one that matters — a machine nobody put an account on is working as
configured, and collapsing it into an error string made a healthy setup read
as broken. A machine with no Claude provider is not asked at all.
**The five-hour window has no reset time between blocks, and that is not a
missing value.** Measured 2026-08-31: the API anchors the window to the block
it started in, and when no block is running there is nothing to reset, so
`resets_at` is `null`. The weekly windows always have one because a week is
always running. So absent means **not running**, and only a timestamp that
arrives and cannot be parsed is unknown. `WindowEnd` in `ResetCountdown.kt`
is the one rule both readers go through.
### Auto-resume (2026-09-05)
**A session may pick itself back up when the account's usage limit lifts.**
Off unless somebody switched that session to it, because it spends quota the
moment quota exists and does so with nobody looking — that is not a thing a
default may decide. It sends one message, `continue` unless another was
typed, and then it is done; there is no retry loop around the conversation
itself.
**Running out of quota is a state, not an error.** `Event::LimitReached`
carries the dialect's reset time where it gave one, and recognising it
belongs to the driver — the Claude CLI ends the turn with `is_error` and
`Claude AI usage limit reached|1788546972`, and nothing above the driver
matches on a string. The transcript draws it as a divider, like a clear or a
compaction: what a reader scrolling back wants from it is why the
conversation stops at that line.
**The schedule is a plan to ask, never a plan to send.** Every reset time
available here is untrustworthy in the direction that matters: the dialect's
is written when the turn fails, and the endpoint's moves when the window
does. So the wait ends in a question to `usage.rs`, and only `ok` with no
window at 100% sends anything. A window still spent reschedules to *its own*
reset time — which is what makes a limit that lifts later than promised wait
longer, and one that lifts sooner resume sooner. A meter that cannot be
asked at all is a longer wait too, never a send: "we could not find out"
must not be able to produce the same action as "there is room".
Bounded, because something has to be: a day after the limit was hit the wait
stops and says so in the session's own transcript. A machine that can never
be asked would otherwise be retried for ever with nothing on screen saying
so.
The schedule is persisted on the session (`resume: Some(ScheduledResume)`),
not held in memory: a five-hour window routinely outlasts a backend restart,
and a wait forgotten across one is a session that silently never comes back.
`resume.rs` is the top layer — it holds the manager and the monitor and
neither holds it — which is what lets the decision be a pure function of a
snapshot and a clock. The pump reports limits downward on a broadcast, for
the reason `Shared` exists: the pump runs underneath the manager.
**Exercised with echo, never with a real account.** `/limit [minutes]` in an
echo session reports the same event a real driver does, and `/usage` sets
what the meter answers — deliberately two commands, because the two
disagreeing is the state the whole design is about. The loop was driven end
to end that way on 2026-09-05: the wait moved from the dialect's two minutes
to the meter's seven when the meter changed its mind, and the message went
out on the first check after the meter came back under the limit.
### Subagents (2026-09-05)
**A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls of its own.** Full design and wire
shape in `SUBAGENTS.md`, kept separate because the app half is being built
against it in parallel and it is the shared contract between the two. The
one-paragraph reason: a session's Task-tool helpers already speak the common
event model on the parent's own stdout (each line carrying
`parent_tool_use_id`), so giving each one its own small transcript — same
file format, same paging routes, same SSE stream, reused by addressing rather
than by copying — costs a routing step in the translator and a registry
(`session/subagent.rs`) rather than a second session type with a driver, a
process and a config entry it does not need.
### HTTP surface
**`routes.rs`'s module doc comment is the table.** REST for actions, one SSE
stream per open session screen for events, all over the pinned TLS listener.
SSE rather than WebSocket because resume-by-cursor (`Last-Event-ID` =
transcript seq) is native to it and the inbound direction is plain POSTs.
Sessions live in `config.ron` (`$XDG_CONFIG_HOME/ai-app/`) plus a per-session
directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript, attachments,
produced images, process record), owner-only. Deleting a session is the
complete path out of everything spawning one created.
**Every request body refuses fields it does not know**
(`serde(deny_unknown_fields)`). A caller that misspells `permissionMode` got
a 200 and a session in the default mode, which is indistinguishable from
success at the place they are looking. Query strings are deliberately
permissive.
**A phone that falls behind is answered with `reset`.** Past
`CATCH_UP_LIMIT` the stream sends a `reset` frame and the newest window, and
the client rebuilds from it exactly as it does when the screen opens. Not
optional: without it the window is spliced onto rows no longer adjacent to
it, which reads as ordinary output. The stream used to replay everything
after the client's cursor, unbounded, while *opening* a session was bounded
to a page — so a long disconnect delivered thousands of events one frame at a
time.
### The file explorer (2026-09-03)
**`EXPLORER.md` holds this design.** The one-line version: a machine's
filesystem, seen from the phone through the backend, keyed on the **setup**
rather than on a session (a session only says where to start), with every
operation one fixed shell script run through `Transport` so the local and the
ssh case are one implementation.
### Security
- **TLS with a self-signed CA, pinned in the app.** Generated in process on
first start into `$XDG_CONFIG_HOME/ai-app/certs`, so one place decides the
extensions, the file modes and which addresses the leaf covers — every
local IPv4 plus loopback and the emulator's host alias, so nobody maintains
a hardcoded IP. The CA is created once and left alone; the leaf is reissued
every start, so covering a new address is a restart. **Regenerating the CA
strands the installed app** — the one-way door.
- Unlike dev-updater, the pinned CA is **not a constant in the source**:
the build reads `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` from the machine
doing the build and generates the constant (`generatePinnedCert` in
`app/androidApp/build.gradle.kts`; `AI_APP_CA` overrides). That does
three things at once — the trust anchor follows the build machine, so an
APK built in the dev VM is only good for its emulator; there is no second
anchor to add for development and forget to remove; and regenerating a CA
needs a rebuild rather than a paste, so a stale constant cannot quietly
disagree with the server.
- **The dev VM is untrusted** (2026-08-25): not malicious, but it could
become so. The repo is a read-write mount shared between the VM and the
backend host, so everything in it — source, binaries, and the shell scripts
the host runs — is attacker-writable.
- **Nothing secret lives in the repo.** A CA private key the VM could read
would let it mint a leaf the pinned app accepts, which is precisely the
attack pinning exists to stop. Transcripts move for a plainer reason:
they are whole conversations.
- **The host should not execute what the VM can write** — build and run the
backend from a host-only checkout rather than the shared mount. Moving
the keys closes the smaller door; this is the larger one.
- Accepted: a compromised VM can return anything it likes from the sessions
it runs, since running an agent there is the point. The blast radius is
that session's content, not the backend.
- **This server's API *is* remote code execution** (spawn a
bypass-permissions Claude on any ssh host). Pinning authenticates the
server to the phone but not the phone to the server, so a bearer token adds
the other direction. The token gates LAN-reachable RCE; it cannot defend a
compromised backend host or phone — those are inside the trust boundary,
and a compromised phone is handled by rotation.
- **No route accepts a command.** Listing, reading and writing files are
fixed scripts in `files.rs`; the phone chooses only the path and the
bytes. Provider discovery asks the machine rather than taking a command.
- **The explorer's routes take a path, and that is deliberate**
(EXPLORER.md's decision 3). Elsewhere the phone picks an **id** and the
server resolves which file it names, so an enrolled token cannot become
"read me an arbitrary file" — the import listing is written that way. The
explorer is different because the path is the whole feature, and it
grants nothing new: the same token already spawns a bypass-permissions
agent in any directory on any machine a setup names. The import rule
stands where it is, because there a path was unnecessary.
- **Generation**: 256 bits from the OS CSPRNG, base64url. A machine
credential, never typed twice, so at this entropy no stretching is needed.
- **Enrollment**: printed once as a terminal QR code encoding
`aiapp://enroll?host=…&port=…&token=…`. The CA is embedded in the APK, so
the QR carries no trust material — photographing the terminal leaks only
the token, never a way to weaken pinning. The app registers an intent
filter for the scheme, and the Settings screen also scans in-app via
`zxing-android-embedded`, because not every phone's stock camera
redirects a scanned URI to an app reliably.
- **Storage**: the server keeps only the SHA-256 in `config.ron`; a plain
hash is enough for high-entropy random input. No "show token again" —
lost means rotate. The phone seals it with an Android Keystore AES-GCM
key (`ServerConfig.kt`; Jetpack's EncryptedSharedPreferences is deprecated
with no drop-in successor and Google's guidance is now "use Keystore
directly").
- **Transport**: `Authorization: Bearer` on every request including the SSE
GET, never a query parameter, since URLs leak into logs. The tracing layer
must not log the header — covered by a test, so a logging change cannot
silently start leaking it.
- **Verification**: one middleware wrapping the entire router, never
per-route, so a new route cannot forget auth. Zero unauthenticated
endpoints, `/health` included. Hash-then-constant-time-compare
(`subtle`); failures logged with peer address plus a small fixed delay —
not against brute force, but so scanners show up in the log.
- **Rotation (the path out)**: `--rotate-token` regenerates, invalidates
the old hash, reprints the QR. Config stores a *list* of `{name, hash}`,
so per-device revocation is a config entry later, not a migration.
- **Why not mTLS**: stronger in theory, but given pinning the delta is only
"someone reads the token off a device already inside the trust boundary",
and it costs Android client-cert provisioning and a worse new-phone
story. Revisit if this outgrows single-user-on-LAN.
- **Off-network access: plain WireGuard** (2026-08-24, no third party). The
backend binds `wg0` only; the phone runs the official WireGuard app,
enrolled by scanning its config as a terminal QR. The only internet-visible
thing is one forwarded UDP port silent to unauthenticated packets, so the
pre-auth surface is reachable only from enrolled peers and the token becomes
defence in depth rather than the sole gate. Addressing stays single-path:
the phone reaches the backend at its WireGuard address from everywhere.
- Accepted operationally: the endpoint is a DDNS name, since the home IP is
not guaranteed static. The WireGuard app resolves it when the tunnel comes
up and does not re-resolve, so a rare IP change is fixed by toggling the
tunnel once DDNS catches up. The symptom is obvious and lossless — the SSE
cursor design replays whatever was missed.
- Rejected: **Tailscale** and **Headscale**, which add a coordination
service this setup does not need at two or three devices; **forwarding
the HTTPS port directly**, which puts every internet scanner one pre-auth
bug away from RCE on a machine holding SSH keys.
- The server refuses to start without TLS, so the token cannot travel
unencrypted by misconfiguration, and binding fails closed — refusing to
start if `wg0` is absent rather than falling back to 0.0.0.0.
`--bind <ip>` is an *explicit, logged* override for development, a
deliberate flag and never a fallback.
## App (`app/`)
Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as
dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
1. **Session list** — kind icon, title, setup, model, status, last activity.
Sessions awaiting an answer sort to the top: the "your turn" inbox.
2. **Import** — Claude Code sessions the machine already has, selected in
batches (hold to enter, tap to add), with Delete and Import along the
bottom. Submitting clears the selection immediately and marks every chosen
row, so the bar goes away and the affected set is what says the work is
happening. Rows are taken out as each one lands rather than all at the
end: a finished row still sitting there looks exactly like one that has
not been imported, and tapping it starts a second CLI on the same
transcript. That makes rows below slide up under the reader's finger, so a
row that has just moved ignores taps for `SETTLE_MS`.
3. **Models** and **Setups** — browsing and downloading GGUFs; adding,
renaming, re-probing and removing machines.
4. **Session screen** — the core:
- The transcript rendered from the event stream: markdown, inline images,
tool cards, question cards.
- **Anything that is a note *about* the conversation rather than a turn in
it is closed by default** — a tool call, a peer message, a memory note.
Open-ness is the screen's, never the card's: a card that remembered for
itself forgets the moment the lazy list stops composing it, so a note
opened and scrolled past would shut behind the reader.
- **An answered question keeps its options and marks the one taken**, in
the same purple that says "picked" while it is open — it does not
collapse into a line repeating the answer. The options are what the
question *was*, and "Deny" alone does not say Allow was the alternative.
One rule in two places (`AskedQuestion` and `PermissionAsk`). An answer
typed into **Other** matches no option, so that one is still written out.
- **Expanding a row keeps still the end nearest the tap**: touch a row's
upper half and its top edge holds, so it opens downwards; touch its
lower half and the bottom edge holds, as the list does by default. Which
half, rather than which control, so everything that opens behaves alike
whether or not it has a control at each end. The transcript is laid out
from the bottom, so a bottom edge is anchored for free and the top one
has to be arranged: `Modifier.holdTopEdge` asks the list to shift during
the *layout* phase, before anything is drawn. From an effect instead,
the wrong position is drawn once first, which reads as a flick.
- **The full-screen image lives on the screen, not in the row that drew
the thumbnail** (`SessionImageViewer`). A `Read` whose result is an image
is a row of one call until the next call arrives and makes it a group — a
different composable in a different part of the tree, so the old subtree
and its open dialog go. Somebody looking at a screenshot was thrown back
to the transcript because the session made another tool call.
- **All transcript text is selectable, from one `SelectionContainer`
around the whole list.** Not per row: a transcript is one body of text,
so a selection has to run from a reply into the tool output under it —
and a container per row leaves whatever was drawn without one silently
unselectable. An inline code chip is drawn *behind* the text rather than
as the renderer's span background, because a span background is part of
the text's own drawing and hid the selection under it.
- Input bar: text, attach, send — **always enabled**; mid-run sends become
steering messages. A queued message can be tapped to take it back.
- The composer's process button (interrupt / stop / start) as above.
### Markdown
**A reply is drawn as pieces of one parse, never as re-parsed substrings.**
A `Piece` addresses a top-level block of the message's tree, or one item of a
top-level list, and every piece is drawn from the same cached parse. That is
what bounds a lazy-list item without parsing a message more than once, and it
is why a forty-item list of sources is forty units rather than one. Links are
spans with one tap detector per text, not a layout node per link — the cost
that made a list of sources bumpy.
**A table wraps its cells and never cuts one off.** The renderer's defaults
draw every cell at one line with an ellipsis, which on a phone loses most of
a table — and an elided cell looks exactly like a short one. `LinkedTableRow`
gives a cell as many lines as it needs, aligned to the top of the row so a
two-line cell does not re-centre its neighbours. A column narrows to 136dp
and no further, past which the whole table scrolls sideways; 136 because it
is the widest floor that still fits three columns across a phone. Exercise it
with the echo driver's `/table N`, which writes long cells on purpose — a
fixture of tidy one-word values renders fine either way.
### The transcript cache
The backend's transcript is the source of truth, and the app keeps a copy of
what it has already been sent — see **`TRANSCRIPT_CACHE.md`** (2026-09-04),
because reopening a session over the tunnel was re-downloading a conversation
the phone had just read. It is the server's own event lines, per session,
under `cacheDir`; it is checked against the server before a stream is resumed
from it, thrown away rather than patched when that check fails, and **never
load-bearing** — every path that reads it has a network path beside it giving
the same answer. What the app does not keep is anything *derived*: the folded
rows are rebuilt from events every time.
### Notifications: two places, never both (2026-08-30)
`GET /notifications` is one SSE stream of attention-wanting moments, and the
app decides where each one is said. Three outcomes, in one place
(`NotificationService.show`):
- **Nothing at all** if the session is the one on screen. The transcript in
front of the reader is already saying it.
- **A banner over the app** if the app is up — `SessionAlerts`, queued, one
per session replacing that session's own, dismissable by a push in either
direction and otherwise retiring itself when the bar across its foot runs
out.
- **A row in Android's drawer** otherwise, which is what the foreground
service exists for.
Never two of them for one moment. A drawer that fills up behind an app that
showed you each one is a drawer nobody reads. Which of the three applies is
answered without a flag anybody has to keep level: the session on screen is
registered by the one composable that draws one, and "the app is up" *is* the
banner queue being collected, since it collects only while it is on screen.
**What counts as finished** is decided in `notification_for`, and since
2026-08-31 it takes the number of messages the session has been given and not
started reading. With one waiting, a turn ending is not the work ending: a
message written into the tail of a turn is read the moment that turn's
`result` lands, so the session goes idle and immediately runs again — and the
phone that sent it was told its work had finished seconds before any of it
was done. The count is kept in `pump`, the one place that sees every event in
transcript order. It does not suppress *awaiting input*: a question is worth
saying whatever is queued behind it.
Rejected: giving the app its own connection to `/notifications` while it is
in front. That is a second stream per device saying the same thing, and it
puts the "which of these two shows it" decision in two processes' worth of
code instead of one function.
### Deferred polish
Noticed and deliberately not fixed, so they are not re-found from scratch.
- **The session screen's header is lopsided.** The row is
`padding(horizontal = 8.dp)`, so the status on the right sits exactly 8dp
from the edge while "Back" on the left is a `TextButton` whose touch target
is wider than its text. It is the "align the mark, not the box" case:
either align the button's content or size the button to what it draws,
rather than nudging with a hardcoded offset.
## Status
Phases 13 (the skeleton pipe, the full Claude driver, the usage screen) done
2026-08-24. Phase 4 (llama.cpp: model browsing, downloads, and `llama-server`
through its OpenAI-compatible endpoint) and phase 5 (ssh) done 2026-08-28,
except for the remote `llama-server` and its port forward, which landed
2026-09-04. The file explorer and the transcript cache followed in September. What is
left is real-phone/WireGuard bring-up, which is operational rather than code.
Each phase ended runnable and verified against the real thing. The backend
gets tests where logic is pure — event normalization, transcript cursors,
config persistence, the syntax scanner; the app is UI over the API and is
verified by running it, matching dev-updater's posture.
## Open questions and risks
- **The Claude stream-json control protocol** is the least-documented
dependency and is version-coupled to the installed CLI. What works is
pinned in `session/claude.rs`'s module doc against the version it was
measured on.
- **The usage endpoint is undocumented** and has changed rate-limit
behaviour before; treat as best-effort.
- **Compaction for llama sessions is not built.** `LlamaDriver::compact`
refuses. The design when it is built: every response reports prompt and
completion token counts, so track them against `n_ctx` (from `/props`), and
at ~75% summarize all but the last few turns and replace them, keeping the
full pre-compaction transcript on disk so the phone's view never loses
history. llama-server's own `--context-shift` is rejected as the strategy:
it truncates old KV cache entries, which is silent forgetting with no
summary, and it corrupts the harness's view of what the model knows. Fine
as a server-side safety net; not memory management.
- **Remote llama-server** needs its port forwarded (`ssh -L`) and is not
built; such a session is refused rather than misdirected.
- **Claude sessions over ssh need the remote machine logged in to Claude.**
Usage reporting reads each machine's own credentials, so this is visible
rather than silent.
## References
- llama-server API (`/health`, `/props`, OpenAI-compatible endpoints):
https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
- Usage endpoint (`GET https://api.anthropic.com/api/oauth/usage`, bearer
token from `~/.claude/.credentials.json`, headers
`anthropic-beta: oauth-2025-04-20` + `User-Agent: claude-code/<version>`,
≥180 s polling; a wrong User-Agent lands in an aggressive 429 bucket):
https://github.com/anthropics/claude-code/issues/31637
- The sibling project this repo's conventions mirror: `../dev-updater`
(README.md + AGENTS.md — server/registry/routes layout, cert scheme,
testing posture).
+3806
View File
File diff suppressed because it is too large. Load diff
+496
View File
@@ -0,0 +1,496 @@
# How iris should render an unbounded number of images
## Status (2026-09-04)
**Implemented**, on the `rustify` branch of `ai-app-2`, in `iris/core` and
`iris/src/default/render.rs`. See "Implemented, 2026-09-04" at the bottom for
what landed, what differs from the proposal below and why, and what was
verified versus merely reasoned about. The short version: the binding array
is gone, `request_device` asks for no features and no binding-array limits,
and that is now proven on the emulator's software Vulkan
(`rigs/gpu-probe`), not just read from the code. `RUST.md`'s blocking item
is resolved.
Iris (the person) asked whether iris's (the library's) approach to
"draw however many images happen to be on screen" — relevant here because a
transcript can hold an unbounded number of attached screenshots — actually
works on mobile, her recollection being that it does not. Checked rather
than assumed, on 2026-09-04, on the `rustify` branch of `ai-app-2`. This
file is that investigation and the resulting recommendation, written for a
second agent to review before anything in iris's render core changes — no
code has been written against this yet.
## The problem
Every texture iris ever creates — every `Image` widget
(`iris/src/widget/image.rs`) and every glyph atlas page — gets a permanent
slot in one array via `Textures::add` (`iris/core/src/primitive/texture.rs:65`).
Both of iris's texture-sampling primitives (`TEXTURE` and `GLYPH`) read that
array by index: `core/src/render/shader.wgsl:56` declares
`var views: binding_array<texture_2d<f32>>`, sized by
`UiLimits::default()` (`core/src/render/mod.rs:347`) at **100,000 textures,
1,000 samplers**. Getting a device to accept that layout needs three wgpu
features — `TEXTURE_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
`PARTIALLY_BOUND_BINDING_ARRAY` — which correspond to Vulkan's
`VK_EXT_descriptor_indexing` ("bindless"), promoted to Vulkan core at 1.2.
A transcript with an unbounded number of image attachments is exactly the
case that grows this array without bound: each attachment becomes its own
`Image` widget, which takes its own permanent array slot until dropped.
## What was measured
**A new rig, `rigs/gpu-probe`**, asks a device for exactly iris's features
and limits with no window and no APK — a plain executable pushed with
`adb push` and run from `/data/local/tmp`. It has two parts:
`wgpu::Adapter::request_device` with iris's exact `Features`/`Limits`
(`src/main.rs`), and a raw Vulkan query bypassing wgpu entirely via `ash`
(`src/vk.rs`), to tell "the driver doesn't have it" apart from "wgpu didn't
detect it."
- **On this VM's own GPU** (Vulkan via Venus onto an RX 7900 XT):
`IRIS DEVICE: ok`. Not the case that matters — nobody's phone is a
discrete desktop GPU — but it is why the design was never checked before
now: it always worked in the one place it was tried.
- **On the Android emulator's guest Vulkan**, both ICDs it ships
(`vk_swiftshader_icd.json` and, cold-booted, `lvp_icd.json`/lavapipe):
`request_device` **fails**
`Unsupported features were requested: TEXTURE_BINDING_ARRAY |
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
PARTIALLY_BOUND_BINDING_ARRAY`. The raw `ash` query on lavapipe shows the
driver itself reporting all seven descriptor-indexing sub-features as
`true` at device API version 1.3 — so wgpu-hal's own feature detection is
being more conservative than the driver here, for a reason not chased
further (a likely instance-version negotiation gap, since the extension
only promoted to core at 1.2). That part is a wgpu-hal/emulator question,
not the finding that matters, and is **not** why this design is rejected.
**The finding that matters is about real phones, sourced rather than
recalled:**
- The **Android Vulkan Profile 2025** — Google and Khronos's current
baseline, covering **80.1% of active Vulkan-capable Android devices** as
of October 2025
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)) —
does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
(indexing by a value uniform across the invocation — Vulkan 1.0 baseline,
unrelated to bindless) and stops there; true of the 2021 and 2022
profiles as well.
- Arm's own developer documentation states **"`VK_EXT_descriptor_indexing`
is supported on all Valhall and 5th Gen GPUs"**
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
Mali generations from roughly 2019 (Mali-G77) onward, with no claim made
for Bifrost, Midgard or Utgard, which are still common in budget and
older Android phones that are still in daily use.
- A search engine's summarized claim of "1% support on Android" for this
extension was checked against its cited source (an Arm blog post from
2021) and **was not actually there** — that number does not appear in
any primary source found and should not be repeated. The baseline-
profile finding above is the one with an attributable source; use it
instead.
So this is not a software-renderer artifact. A real, currently-shipping
share of the Android fleet lacks the feature iris's texture pipeline asks
for unconditionally, and neither the emulator's failure nor the current
official hardware baseline gives any reason to expect that to change soon.
## What growth already costs today, before any redesign
Checked directly in `core/src/render/mod.rs` and `core/src/render/texture.rs`,
because "does this redesign make things worse" needs the current baseline
first:
- The `RenderPipeline` (`UiRenderNode::new`) is created **once** and never
rebuilt for any reason related to texture count — its bind group
*layouts* declare fixed slot counts (`limits.max_textures`,
`limits.max_samplers`) up front and that never changes at runtime. Growth
was never at risk of recreating the pipeline, in the current design or
any redesign discussed below.
- What **does** get rebuilt: `UiRenderNode::update` calls
`self.textures.update(&mut ui.textures)`, and if that reports any change,
rebuilds `self.rsc_group` — one `BindGroup` whose entries are
`BindingResource::TextureViewArray(&tex_manager.views())`, collected
fresh over **every currently-live texture**, plus the sampler array and
the mask buffer. This happens on every texture `Push`, `Set`, or `Free`
— an image added anywhere in the whole app rebuilds one shared structure
referencing every other image too.
- The one path already excluded from this, on purpose, is a `Patch`
writing into an existing texture's pixels without changing which
textures exist. The code says why directly
(`core/src/render/texture.rs`, in `GpuTextures::update`): *"A patch
changes texture contents, not the binding array, so it must not report
`changed` — rebuilding the bind group per glyph is the cost this exists
to avoid."* This is exactly the mechanism I1 built for the glyph atlas:
growing an existing atlas page costs a `write_texture` into a sub-rect,
nothing else.
So today, growth that stays inside an existing texture (glyphs added to an
atlas page) is already free. Growth that adds a *new* texture — a new atlas
page, or any standalone image — already rebuilds the one shared array
regardless of how the array is populated, before any change discussed
below. That existing cost is O(live texture count) in CPU work to collect
the view list and in however expensive the driver finds a
descriptor-set-sized-for-N-descriptors to be.
## Prior art, checked rather than assumed
Two independent projects were checked to see whether "atlas for images"
is actually how this is normally done, rather than a guess:
- **egui_wgpu** (`crates/egui-wgpu/src/renderer.rs` in emilk/egui), the
closest prior art to iris — an immediate-mode wgpu-backed UI library that
ships on Android. It keeps a `HashMap<TextureId, Texture>` and gives
**each texture its own ordinary `BindGroup`** — one texture, one sampler,
no array, no descriptor indexing of any kind. Draw calls are batched by
texture id and the bind group is switched between batches within the
render pass.
- **Vello** — the renderer Masonry (E1/E2's Linebender stack) draws
through — hit the identical problem and wrote down why in their own
roadmap document
([github.com/linebender/vello/blob/main/doc/roadmap_2023.md](https://github.com/linebender/vello/blob/main/doc/roadmap_2023.md)):
*"The number of images that may appear in a scene is not bounded, which
is not a good fit for the basic descriptor binding model... Until then,
we'll do a workaround of having a single atlas image containing all the
images in the scene."* Their reason is broader than Android — WebGPU 1.0
has no descriptor indexing at all — but it reaches the same conclusion
for the same shape of problem: atlas, not a bigger bindless array.
**This is also a live hazard, not a solved one.** Vello's own changelog
(Sparse Strips v0.2.0) lists a fix titled *"WebGL image-atlas allocation
and growth on Mali-G52 GPUs, avoiding application-not-responding errors"*
— an actual ANR, from atlas growth, on an actual mid-range Android GPU,
in the renderer Masonry is built on. The same release added
`AtlasSpaceDiagnostics`/`AtlasLayerDiagnostics` (per-layer free-space,
utilization, fragmentation) because growth needed instrumenting in
production, not because it turned out to be free.
## Recommendation (not yet implemented)
1. **Small, plentiful textures** — glyphs (already done, I1), thumbnails,
downscaled attachment previews, icons — go through a shared atlas, the
same technique as `core/src/render/atlas.rs` generalized beyond glyphs.
Adding one to an existing page is a `Patch`, already free per the
section above.
2. **Large or one-off images** — a photo attachment opened at full
resolution, anything that would fragment a shared page — get their
**own ordinary, non-array bind group**, the egui_wgpu way. Creating one
is O(1): it references only itself, and does not touch any other
texture's binding, unlike today's shared array where every push
rebuilds a structure listing everything.
3. **Opening a new atlas page** is the one case that still resembles
today's rebuild — infrequent (bounded by how many *pages* are needed,
not by how many images have ever been attached) but not free, and
Vello's Mali-G52 fix says this specifically deserves care: it should
never be allowed to block a frame, and it is worth having the
equivalent of Vello's atlas diagnostics before trusting it under load.
4. **Net effect**: dropping `TEXTURE_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`, and
`PARTIALLY_BOUND_BINDING_ARRAY` from iris's device request entirely.
Every path above is plain Vulkan 1.0 / GLES-level texture sampling.
This is also what fixes the emulator failure measured above, regardless
of the unresolved wgpu-hal question: a device that never asks for the
feature cannot be refused for lacking it.
## What this touches, and what is still open
Implementing this reworks iris's rendering core: the shader's binding
group layout (`shader.wgsl`), `Textures` and `GpuTextures`
(`core/src/primitive/texture.rs`, `core/src/render/texture.rs`), both
texture-sampling primitives, and `core/src/ui/painter.rs`'s draw-call
batching (today one draw call can reference any texture by index; the
per-texture-bind-group path needs draws grouped by which bind group they
use). Nothing has been started.
Open questions a reviewer should weigh in on:
- **The size threshold** between "goes in an atlas page" and "gets its own
bind group." Too low and ordinary attachment thumbnails end up as
one-off bind groups, losing the batching benefit the atlas exists for;
too high and a page fragments on a handful of medium images.
- **Eviction policy** for atlas pages once the working set does not fit —
today's `GlyphAtlas` never evicts, because a font's glyph set is small
and bounded; images are not. An LRU at the page level, or at the
individual-image level within a page, has not been designed.
- **Whether iris should keep any binding array at all**, even a small
fixed one (say, capped at a few dozen slots) for atlas pages themselves,
or whether every atlas page should also be its own ordinary bind group
like standalone images — the array's only remaining justification would
be avoiding a bind-group-per-draw-call switch cost that has not been
measured on this project's actual target hardware.
- **How this interacts with I2/E2's virtualised list** (I3): a
bottom-anchored transcript composes only visible rows, so the live
texture set should already be bounded by what is on screen rather than
by the whole conversation — worth confirming that invariant holds before
relying on it to keep atlas/bind-group churn small.
## Review, 2026-09-04
A second pass over the file above against the code, done before anything
is implemented. Iris's worry going in: a bind group per texture means a
draw call per image, and she wants this as efficient as it can be.
### What checked out
Every code reference above is accurate as of this commit: the 100,000 /
1,000 limits, the one-time pipeline, the `rsc_group` rebuild on every
`Push`/`Set`/`Free`, and the `Patch` exclusion. The device request that
asks for the three features is `iris/src/default/render.rs:96`, which the
text above does not name. egui-wgpu and Vello are described correctly.
### The emulator refusal is a wgpu-hal gap, now located
The file guessed "a likely instance-version negotiation gap." It is
narrower than that and it is in wgpu-hal, not the emulator. wgpu-hal
28.0.0 (`src/vulkan/adapter.rs:1618`) only queries
`PhysicalDeviceDescriptorIndexingFeaturesEXT` **when the device advertises
the `VK_EXT_descriptor_indexing` extension string**. A Vulkan 1.2+ driver
that has descriptor indexing as core need not list the extension, and
lavapipe at 1.3 evidently does not, so wgpu never asks and reports the
features absent, which is why `ash` sees seven `true`s and wgpu sees none.
The properties query beside it (line 1486) correctly accepts
`device_api_version >= 1.2 || extension`; the features query does not.
wgpu-hal 30.0.1 in the local registry has the same asymmetry (lines
1872 and 2036). Worth an upstream issue, but not a reason to keep the
design: on real phones the gate that matters is stricter still.
**wgpu's `TEXTURE_BINDING_ARRAY` needs six sub-features, not one**
(`adapter.rs:160-177`): non-uniform indexing *and* update-after-bind for
sampled images, storage images and storage buffers, all together, because
wgpu marks every array-bearing descriptor set update-after-bind. So Arm's
"the extension is supported on Valhall" is necessary but not sufficient;
a driver with sampled-image indexing and without storage-buffer
update-after-bind is refused too. That widens the excluded set beyond
what the Arm quote suggests and strengthens the conclusion.
### A live bug in the current code, found on the way
`GpuTextures::update` (`core/src/render/texture.rs:33`) implements
"a patch must not report changed" as `changed = false`, unconditionally,
which also **cancels a `Push` earlier in the same batch**. That ordering is
exactly what opening a new atlas page produces: `GlyphAtlas::allocate`
pushes the page and `insert` patches it in the same frame, so the bind
group is not rebuilt and the new page's view is not bound until some
unrelated texture change happens to rebuild it. It is hidden today only
because the masks path also sets `changed`. The fix is one line
(`changed |= !matches!(update, Patch)` in spirit); it should go in with
the redesign since that code is being replaced, and it is recorded here
so it is not rediscovered.
### In-layer draw order is already undefined
Relevant to any batching redesign: `Primitives::apply_free`
(`core/src/render/primitive.rs:147`) uses `swap_remove`, so the instance
order within a layer is permuted whenever anything is freed. Overlap order
inside one layer is therefore not something the renderer promises today;
ordering is done with layers. That means grouping a layer's draws by
texture, or drawing a layer's images after its rects and glyphs, loses
nothing that currently exists. It should be written down as an invariant
when the redesign lands, because the new code will depend on it.
### On "a draw call per image"
Two corrections to the worry. First, it is a draw per *distinct texture per
layer*, not per image primitive: every glyph quad in a layer shares the
atlas and stays one instanced draw, and a thumbnail atlas would do the same
for previews. Second, the count is bounded by what is on screen, which I3's
virtualised transcript already bounds, and a mobile GPU is not draw-call
bound at tens of draws per frame; egui ships exactly this on Android. What
does cost is per-frame *bind group creation* and per-frame *sorting*, and
the current code already creates a `primitive_group` bind group every time
a layer updates (`render/mod.rs:103`), so one more per new image is not a
regression in kind.
### Recommended shape (proposal, for Iris to accept or change)
Aimed at the fewest moving parts that need no feature beyond Vulkan 1.0:
1. **Atlas pages become layers of one `texture_2d_array`**, not separate
textures. Every page is already `PAGE`x`PAGE` RGBA8, which is the one
constraint an array texture imposes. A layer index is an ordinary
sampling operand in WGSL and needs no indexing feature, so `GLYPH`
(and any future atlased-image primitive) carries a layer instead of a
`view_idx` and all of a layer's text stays **one draw**. This answers
the open question above about keeping a small binding array: no. Cost
of opening a page: recreate the array with one more layer and
`copy_texture_to_texture` the old ones, GPU-side, no readback; grow
with headroom (double) so it is rare. wgpu's default
`max_texture_array_layers` is 256, at 4 MB each, so the cap is memory
rather than the API.
2. **Every standalone image is its own texture with its own bind group**,
and its instances live in a **separate per-layer instance list**, not
the main one. Then the main instance buffer never contains an image,
there is nothing to sort, no handle remapping beyond what
`apply_free` already does, and each image is `draw(0..4, k..k+1)` with
its bind group set first. Group 2's layout becomes `{atlas array,
one image texture, sampler, masks}`; the main draw binds a 1x1 null
image in the image slot, each image draw binds its own. One pipeline,
one shader, one layout.
3. **No thumbnail atlas in the first version.** With images on their own
textures, the threshold and eviction questions above disappear: an
image is freed when the row that owns its `TextureHandle` scrolls out.
Add an image atlas only if a measured screen shows enough small images
to matter, which a transcript rarely does.
4. **Drop the three features and the two `max_binding_array_*` limits from
`src/default/render.rs`**, and the `UiLimits` counts with them.
5. **Sampling is `NonFiltering` today** (`render/mod.rs:290,299`), so a
downscaled attachment will alias. Either request a filtering sampler
for the image slot or downscale on the CPU before upload; decide when
the image widget is touched, not as part of this.
What this costs against the file's original recommendation: `Textures`
needs to know an image from a page (two kinds of handle, or a kind on
`TextureHandle`), and `Primitives` gets a second instance list per layer.
What it saves: the sort, the size threshold, the eviction policy, and any
per-page bind group switch.
## Implemented, 2026-09-04
The shape above, built as proposed with one structural addition the proposal
didn't need to spell out and one bug it predicted made moot rather than
literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`,
`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`),
`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`),
`core/src/render/atlas.rs`, `core/src/ui/painter.rs`,
`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed),
`core/src/render/shader.wgsl`, `src/default/render.rs`, and
`rigs/gpu-probe/src/main.rs`.
**1. Atlas pages as array layers.** `GpuTextures` owns one
`texture_2d_array` (`array_texture`/`array_view`), grown by doubling
(`grow_array`): a new texture is created at twice the layer capacity, the
old layers are copied across with `copy_texture_to_texture` (GPU-side, no
readback), and every bind group that referenced the old view — the main
one and every live standalone image's — is rebuilt, since the view's
identity changed. `GlyphPrimitive` carries `layer: u32` instead of
`view_idx`/`sampler_idx`; the layer number is assigned synchronously in
`Textures::add_page` (a plain counter, `next_page_layer`), not by the
renderer, because `GlyphAtlas::insert` needs it in the same call, before
any GPU sync happens — the renderer only finds out later, when it
processes the queued `Push`.
**2. Standalone images, one bind group each.** `TextureKind` on
`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index,
`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list
`images: Vec<PrimitiveInstance>`, tagged `IMAGE_BINDING` — separate from
`instances` (rects and glyphs), written by `Painter::write_image` rather
than through the generic `Primitive` trait, since an image has nowhere in
`PrimitiveData` to put a per-instance entry once the bind group already
picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer
once as before, then walks `image_instance` one entry at a time, binding
that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing
`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed
`{atlas array, one image texture, sampler, masks}`; the main draw binds a
1x1 null view in the image slot.
**The one addition beyond the proposal**: the masks storage buffer lives
in every per-image bind group (group 2, binding 3), and `ArrBuf<Mask>`
recreates its buffer whenever the mask count changes size
(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized).
A resize invalidates every bind group holding the old buffer, not just the
main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls
`rebuild_image_bind_groups` when it's set, alongside the same rebuild the
array-growth path already needed. This wasn't a design question the
proposal had to answer (it treated bind-group construction as a given),
but it's exactly the shape of trap layer growth already had, so it uses
the same fix.
**3. No thumbnail atlas.** Not built, as proposed.
**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from
`src/default/render.rs`'s `request_device`, and `UiLimits` (the type
itself, not just its binding-array methods — once its two fields were
gone there was nothing left in it, and `UiRenderNode::new` no longer takes
a limits parameter). `binding_array` no longer appears anywhere in
`shader.wgsl`.
**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own
note that this is a separate decision for whenever the image widget itself
is touched.
**The `changed = false` bug is structurally gone, not patched.** The old
`GpuTextures::update` held one `changed: bool` that a `Patch` reset
unconditionally, which could erase an earlier `Push` in the same batch (a
new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s
`Patch`, both queued before the renderer ever runs). The new `update`
computes the rebuild signal by OR-ing each event's own answer
(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never
contributes to it — there is no shared mutable flag left for a `Patch` to
stomp on. Documented at the call site
(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the
`Patch` match arm's comment) rather than fixed as a one-line diff, since
the mechanism that could go wrong no longer exists.
**In-layer draw order is an explicit invariant now, not just a fact about
`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its
rects and glyphs, and `Primitives::apply_free`'s doc comment states
directly that both of a layer's lists (`instances` and `images`) free with
`swap_remove` and that nothing may assume adjacency survives a free —
recorded there because `apply_free` is the one place a change to either
list's ordering would have to be reconciled.
**Verified:**
- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
`cargo clippy --all-targets`, `cargo test --workspace` all clean in
`iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass
(unchanged from I1; nothing here is pure-logic enough to add a unit
test to — it's all GPU resource wiring).
- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and
`iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly
on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every
panel, confirming `GlyphPrimitive.layer` addresses the array correctly.
- The standalone-image path specifically: a throwaway example (not
committed) with an `image(...)` widget as part of the root, run the same
way, rendered the image next to glyph-atlas text in one frame —
confirming a live `BindGroup` built by `GpuTextures::create_image` and
bound per-`draw()` call actually samples the right texture. `tabs`'s own
"image span" tab exercises the same widget but needs a click to reach,
which the headless compositor can't deliver (no seat devices, per I1's
own note on this file) — the throwaway example is what stood in for it.
- **Exercised, 2026-09-04: `grow_array` under real load, on `tabs`.**
Rather than building a purpose-made glyph flood, `PAGE`
(`core/src/render/atlas.rs`) was temporarily dropped from 1024 to 64 —
small enough that `tabs`'s ordinary mix of sizes and families (nothing
exotic: a handful of `Text` widgets at a few sizes, one at
`Family::Monospace`) already exceeds one page's worth of distinct
glyphs. A one-line `eprintln!` in `grow_array` confirmed two real grows
in a single run (`GROW_ARRAY: 1 -> 2` then `GROW_ARRAY: 2 -> 4`, i.e.
glyphs landed on at least a third layer), and
`iris/run-headless.sh tabs --shot` showed every tab's text rendering
correctly with no corruption or missing glyphs — confirming the
`copy_texture_to_texture` grow-and-relocate path and cross-layer
sampling (`GlyphPrimitive.layer` addressing a layer beyond the first)
both work. Command:
`sed -i 's/PAGE: u32 = 1024/PAGE: u32 = 64/' core/src/render/atlas.rs`,
rebuild, `./run-headless.sh tabs --shot /tmp/x.png`, then
`git checkout -- core/src/render/atlas.rs` to revert — this is a
throwaway diagnostic value, never a committed change, since a real
1024px page holding only a handful of glyphs at a time would be mostly
wasted space in normal use. Confirmed the revert left `tabs` and
`minimal` byte-identical to the pre-check screenshots afterward.
- **The decisive check**, `rigs/gpu-probe` rewritten to request iris's new
(empty) feature/limit set and run on this checkout's own emulator
(`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets
a real Vulkan device (SwiftShader) rather than the `-gpu host` default,
which disables Vulkan in this VM entirely (`-feature -Vulkan`, because
gfxstream can't pair Venus with the real GPU here — worth remembering,
since the *default* `emu up` gives a device with **no** Vulkan adapter
at all, which reads exactly like the old bindless failure if you don't
know to ask for `EMU_GPU=software`):
cd rigs/gpu-probe
ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \
cargo ndk -t arm64-v8a -P 26 build --release
EMU_GPU=software emu up # from ~/repos/emulator-tools
adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/
adb shell chmod 755 /data/local/tmp/gpu-probe
adb shell /data/local/tmp/gpu-probe
Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`,
`features iris requires:` (none listed — the set is empty),
`max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix
measured working, on the exact rig that first measured it failing.
Emulator stopped afterward (`emu down`); nothing was left running.
+35
View File
@@ -0,0 +1,35 @@
# TODO
Working list from Iris, 2026-09-03. Remove an entry when it lands; annotate
one in place when it turns out to need a decision.
## App — transcript
- [ ] Messages received from other agents are inconsistent — sometimes they
appear, sometimes they don't. **Needs a rig.** Read the code rather than
measured: a live Claude session only learns of a peer message from the
`origin` object on a turn's `result`
(`session/claude/translate.rs`), which the CLI attaches to a turn the
message *started*. So a message that arrives mid-turn, or a second one
within one turn, has nowhere to be reported — while an imported session,
which syncs from the CLI's own file, picks up every one of them. That
would show exactly as "sometimes". Confirming it means driving a real
stream-json session and sending it messages in both states.
## Session settings
- [ ] Autocompact belongs in session settings; empty disables it, which is the
default. Iris chose "hand it to the driver" — only where a driver has
auto-compaction of its own. **That option was offered on a false premise
and is not buildable yet.** It named pi's `set_auto_compaction`, but pi
was never built as a driver here: `session/llama.rs` talks to
`llama-server`'s OpenAI-compatible endpoint directly, and its `compact()`
refuses outright. Claude Code's auto-compaction is the CLI's own and
nothing in the stream-json control protocol this app uses configures it.
So the setting would be stored, passed to a driver, refused by every one
of them, and the field would never appear on any session. What is needed
first is either a driver that can take it, or a different rule — the
server watching `contextTokens` and running `/compact` itself is the one
that would work today, for Claude sessions, and it is the option that was
not chosen.
+427
View File
@@ -0,0 +1,427 @@
# The transcript cache
Asked for by Iris on 2026-09-04 and built the same day: keep the transcripts
of recently visited sessions on the phone, so reopening one does not download
it again. It has to save data over the tunnel, must not disturb a reply that
is streaming when the screen is reopened, must never skip an event, and needs
a manual reload for when the file on the machine has changed under it.
Like EXPLORER.md this records each decision with its reason and what was
rejected, so that when one changes it is changed here rather than re-argued.
"What building it changed" at the foot says which of them moved while it was
being built. How to exercise it, and what has bitten, are in AGENTS.md.
## What it is, in one paragraph
A per-session file on the phone holding the exact JSON lines the server has
already sent, in transcript order, with a record of which sequence numbers
each run of lines covers. Everything the session screen fetches — the opening
window, the pages it scrolls back through, the span an anchor restore reaches
for — is asked of the cache first and of the server only for what the cache
does not hold, and everything that arrives from the server is written into
it. The live stream then resumes from the newest cached event, exactly as it
resumes from the newest event on screen, so the server sends only what
happened since. One tiny request checks that the cached tail is still what
the server has before the stream is opened from it, and a button in session
settings throws the cache away and rebuilds the screen as a cold open for the
cases that check cannot see.
## The invariants
When a decision below looks arbitrary, it is one of these forcing it.
1. **What is on screen is what the server's transcript says, in order, with
nothing missing, for every sequence number the screen claims to show.**
The cache is a copy of server output and is never inferred, folded, or
edited on the phone. Where the copy cannot be shown to be current, it is
thrown away, not patched.
2. **A cached line is never ahead of the live cursor, and the live cursor is
never ahead of the cache.** The stream resumes from the newest cached
event, so a reply that was mid-stream when the screen closed picks up at
its next delta and folds into the same row.
3. **The cache is never load-bearing.** A missing, evicted, corrupt or
unwritable cache degrades to a cold open, never to a blank or wrong
screen. Every path that reads it has a network path beside it producing
the same result.
4. **Data crosses the tunnel once.** A line already on the phone is not
fetched again unless the reader asks (the reload button) or the check in
decision 3 says it must be.
## Decisions
### 1. Raw server lines, on the phone, keyed by server and session
The cache stores the server's own JSON, one event per line, byte-for-byte as
it arrived: the elements of the `/transcript` array and the `data:` payload
of each SSE frame. Reading the cache runs the same `parseSeqEvent` the
network path runs, so a cached transcript and a fetched one cannot draw
differently, and an event type this build does not know
(`SessionEvent.Unknown`) survives on disk for the build that will.
It lives under `context.cacheDir`, which is exactly what that directory is
for: bytes the phone can regenerate from the server, which Android may delete
under storage pressure without asking. Keyed by the server's host and port,
because two servers can hold a session with the same id (the sandbox and the
real server, or a re-enrolment) and a line from one shown against the other
is invariant 1 broken. The `v1` segment is the format version: any change to
the layout below bumps it, and a directory of another version is deleted on
first use.
Rejected: a database (Room, SQLite). The access pattern is "the newest N
lines" and "the lines before seq X", on files of tens of megabytes at most,
and a JSONL file per contiguous run answers both by reading from its end. A
database would be a new dependency for an index the file layout provides.
Rejected: caching folded `TranscriptItem` rows instead of events. Rows are a
*rendering* of events, and their shape changes when the fold changes; the
cache would need invalidating on every app update that touched `foldEvent`,
and would still have to keep raw seqs for the stream cursor. Events are the
server's contract and the only thing that is stable.
### 2. Chunks with explicit coverage; one contiguous run behind the cursor
A page from the server 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
`assistantText` deltas into one event carrying the seq of its *oldest* delta,
so a page whose newest event has seq 1,200 may in fact cover every line up to
the `before` it was asked with, say 1,650. Nothing in the lines themselves
says so. So each stored chunk records its coverage as a half-open range
`[first, end)`, where `end` is the `before` the request was made with — or,
for a raw chunk, its newest seq plus one.
Chunks are files named by their coverage:
<first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
<first>-<end>.raw.jsonl an uncoalesced page or a closed live run
<first>-open.raw.jsonl the live run: appended to by the stream
Two chunks are **adjacent** when one's `end` equals the other's `first`. The
cache serves only the contiguous run of adjacent chunks that ends at the
newest raw chunk (the **suffix**); chunks behind a gap are kept on disk,
because the gap is usually filled (decision 4), but are never served across
it.
**The newest chunk is always raw.** That is what makes the stream cursor and
the probe well defined: a raw chunk's last line is a real event at a real
seq, and the server never coalesces the newest window. It holds by
construction — the opening window is fetched with no `before`, stream frames
are raw, and a `reset` window is raw — and is *checked* on read: a `.rows`
chunk found newest (which can only happen if the app died between closing one
live run and appending to the next) purges the session's cache.
There is at most one open chunk. A stream event whose seq is not the open
chunk's `end` — which is what a `reset` looks like from here — closes it by
renaming it with its real end and starts a new one. An event whose seq is
below the open chunk's `end` is already covered and is not written; the SSE
contract is `seq > after`, so that is a guard rather than a path.
Rejected: one file per session, rewritten to prepend older pages. A 20 MB
transcript would be rewritten on every page scrolled back to. The chunk
directory costs a directory listing per open instead.
Rejected: trimming chunks to resolve overlaps. A coalesced event cannot be
split at a seq inside its run, so an overlap between a coalesced page and an
existing chunk has no clean cut. The cache therefore **never stores a page
that overlaps an existing chunk**; decision 4 makes sure such a page is never
fetched, and one that arrives anyway is used for display and not stored.
### 3. The cached tail is checked against the server before the stream opens from it
The transcript file is append-only in ordinary use, but it can be replaced or
truncated — a sandbox re-seeded with the same ids, a backup restored, a
session deleted and re-imported — and `catch_up` on such a file would hand
the phone a continuation of a *different* conversation, spliced onto the
cached one with no seam. That is the worst thing this feature can do, and it
is caught with one request.
**The probe** is `GET /sessions/{id}/transcript?before=<cursor+1>&limit=1`,
where `cursor` is the seq of the cache's newest line. `read_window` with that
`before` returns the single newest event with seq ≤ cursor, which is the
event *at* the cursor when it exists. It passes when that response, parsed
with `parseSeqEvent`, is `==` to the cached line parsed the same way — over
seq, ts, and the whole event. It fails when the response is empty, is a
different seq, or differs in any field.
That equality rested on an assumption this plan stated and did not check:
that the two ways the server hands out a line agree bit for bit. **They did
not**, and the server was fixed — see AGENTS.md's entry on `float_roundtrip`.
Comparing everything *except* `ts` was the other option and was rejected: a
re-seeded fixture is identical in content and differs only in when it
happened, which is exactly the case the probe exists for.
A failed probe **purges the session's cache and proceeds as a cold open**. A
probe that cannot be made leaves the cached transcript on screen, shows the
error on the stream banner where a connection failure shows today, and is
retried on the stream loop's schedule; the stream is never opened until a
probe has passed once for this screen instance.
What the probe does *not* catch: a line changed in the middle of the file
with the tail intact, or a file rewritten so that the event at the cursor
happens to be identical. Those are what the reload button is for, and the
button's caption says so.
Cost: one request of a few hundred bytes, in the slot where the opening
page's request would be — so the round trips before the stream is live are
unchanged at two, and the bytes fall from a page to a line. The cached rows
are drawn *before* the probe returns, which is the whole point; a failed
probe replaces them, with the same appearance as a `reset`.
Rejected: a server-side check on the stream, answered with a distinct frame
when the event at N is not what the phone thinks. Strictly better coverage —
it would run on every reconnect — and no extra round trip. Not chosen because
it puts a cache's validation into a protocol that otherwise knows nothing
about caching, and because the reset frame already has to keep meaning "you
are behind, your history is fine". Worth revisiting if the probe's round trip
is ever measured as the thing making reopen slow.
Rejected: trusting the cache and relying on the reload button. Invariant 1 is
not something a button restores after the fact.
Rejected: fetching the newest page as before and using it to validate the
overlap. Zero saving on the opening page, which is the request paid on every
open.
### 4. Pages ask the server only for the gap: `after` on `/transcript`
After a reader has been away, the cache holds `[a, b)` and the screen holds
the newest window `[W, …)` with a gap between `b` and `W`. Paging back from
`W` asks for a coalesced page before `W`, and that page may reach back past
`b` — a single reply is hundreds of lines, so forty rows can be thousands of
seqs — producing exactly the overlap decision 2 refuses to store. Left like
that, every cached chunk would be dropped in turn as the reader paged back
through the gap, and the cache would save nothing for the sessions it exists
for.
So the transcript route takes a lower bound, `after`, named to match the SSE
route's (exclusive, `seq > after`). `read_window` starts the walk at
`first_at_or_after(after + 1)` instead of at `end - limit`. A delta run cut
at the start is emitted as the partial it is, exactly as one cut by `limit`
already is, and `healSplitMessage` welds it on the phone — no new mechanism.
The phone passes `after = b - 1` where `b` is the `end` of the nearest chunk
whose `end ≤ before`, and nothing when there is none. A page that comes back
with `first == b` is adjacent, and the suffix now runs through the old
chunks: the gap is closed with exactly the bytes it was wide, and the history
behind it is served locally from then on.
Rejected: fetching the gap raw in one request, which is what the anchor
restore does. Exact, but a gap of ten thousand lines is several megabytes
downloaded to save re-downloading history the reader may never scroll to.
Rejected: dropping the cached run whenever a gap opens. Being more than
`CATCH_UP_LIMIT` (200) events behind is the *ordinary* state of an active
session revisited — 200 raw events is one reply — so this would empty the
cache for exactly the sessions that are opened most.
### 5. A page is served locally in rows, mirroring the server's count
`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when coalescing and for
a number of **events** otherwise (the anchor restore). Served from the cache,
the events branch is the `limit` lines before `before`. The rows branch walks
back counting rows the way `parse_coalesced` does — every event that is not
an `assistantText` is a row, and each maximal run of `assistantText` lines is
one row — stopping only between rows. It does not join the deltas; the fold
does that, and the joined row keeps the seq of its first delta either way, so
anchors and the next `before` land where they do on the network path.
A cached page is allowed to be **short**: a walk that reaches the suffix's
oldest chunk returns what it found. The caller already treats a short page as
a page; only an *empty* page means "start of the conversation", and the cache
never returns one — it returns `null` (a miss) and the network is asked.
A miss is `before` **outside what the suffix covers continuously** — above
its newest `end`, or at or below its oldest `first`. This plan first said a
miss was "no chunk of the suffix ends at `before`", which is wrong in the
commonest case there is: a warm open draws the newest eighty lines of the
live run, so the cursor the reader then scrolls back from is in the *middle*
of a chunk. Under the narrower rule every warm open sent its first backwards
page to the server, and that page overlapped what the phone already held and
could not be stored, so the same history was fetched again on every visit.
The feature would have saved the opening window and nothing else.
The row rule is a copy of the server's, and copies drift. It is short, it is
pure, and it is under a JVM unit test with the same fixture as the server's
`coalescing_counts_rows_and_joins_delta_runs` — a run cut by the limit, a
`usageDelta` inside a run (the server flushes the run there, so it is two
rows), and a page that is all one run.
### 6. What a `reset` means for the cache: behind, not wrong
The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT` events
behind, then the newest 200 raw events. For the cache that means **the
history is intact and there is a gap**: the probe passed, the file is
append-only, and the window's first seq is above the open chunk's end. The
store learns this from the first window event's seq and needs no signal from
the screen; the gap is filled by paging.
The reset handler also clears `queued` and `waitingCommands`, which it did
not originally. Both are folded from events, and a `messageQueued` whose
resolving `userMessage` fell in the gap would otherwise draw a waiting bubble
for a message the session has long since read. That was a latent bug made
likely by the cache, because a cached tail is older than a fetched one.
`contextTokens` needs no clearing: `UsageDelta.context` is absolute, so the
window's first one corrects it.
### 7. Session state that is not the transcript comes from the list, not the cache
`apply` derives `status`, `model`, `permissionMode` and `compactingSince`
from `Status` and `Settings` events. Replayed from a fetched page those are
current; replayed from the cache they are as old as the last visit, while the
list row the reader just tapped was fetched moments ago. So the cache replay
runs through `apply` for the transcript's sake and then **reassigns those
four from `summary`**, which is the newer of the two measurements; the
stream's catch-up then makes them current. Without this a session that
finished an hour ago would open saying "working" until the stream connected,
which is a status row lying for a round trip.
### 8. Reload, in session settings
A row under the working directory showing what the button discards:
[ Transcript ] 2.3 MB cached [ Reload ]
The size is the unknown state made visible — `null` while the directory is
being measured (spinner, as the notifications switch does), "nothing cached"
when the directory is absent or empty, else the size. The caption is in the
style of Move's, because the button costs something the reader cannot see:
*"Reload throws away this phone's copy and fetches the transcript from the
server again. Use it when what is shown here disagrees with the file on the
machine."*
Pressing it purges the session's cache directory, closes the dialog, and
rebuilds the screen as a cold open, with the reader put back where they were.
The mechanism is an `epoch` counter in the key of the opening effect and the
stream effect; incrementing it cancels both and relaunches them. `savedAnchor`
is keyed on the epoch too, so the restore reads the anchor saved at the
reader's *current* position. The button is enabled whether or not anything is
cached: "what I see disagrees with the machine" is a state an empty cache can
also be in, and a control that comes and goes makes its own presence the
signal.
Nothing is announced on success — the transcript shows the opening spinner
and then the rows, which is what the screen already says about a reload. A
failure is the opening fetch's, and lands on the stream banner.
Rejected: a global "clear transcript cache" in the app's settings. Not asked
for; eviction bounds the total, and the per-session button is where the
reader is when they notice a problem. Easy to add as one more caller of
`purgeAll`.
### 9. Budget, eviction, pruning
Bounded three ways, each with its path out written beside the path in:
- **Budget.** `CACHE_BUDGET_BYTES` is 256 MB across all sessions of one
server. Each open touches the session directory's mtime; after the opening
replay, on `Dispatchers.IO`, the store sums the server's directories and
deletes least-recently-touched ones (never the one on screen) until under
budget. 256 MB is a dozen of the largest transcripts seen in this VM
(21 MB for 24,000 events) and a small fraction of a phone; it is a number
to revisit against real use, not a measurement.
- **Deleted sessions.** The list screen's delete purges after `deleteSession`
succeeds, and every successful list fetch calls `retainOnly(ids)`, so a
session deleted from another device is pruned on the next visit to the
list. `Drafts.kt` chose not to prune because its residue is bytes; here it
is megabytes.
- **Android.** `cacheDir` may be emptied at any moment, including while a
screen is open. Every read tolerates a missing directory and every write
failure is swallowed once.
### 10. The cache never breaks the screen
Every store operation that touches the disk catches `IOException` and answers
as if the cache were empty: `null` from a read, no-op from a write, logged
once. After a write failure the instance stops writing, so a full disk costs
one log line rather than one per delta. A line at the end of an open chunk
that does not parse — the app died mid-write — is dropped and the file
truncated to the last good line before anything is served from it; a line
that does not parse anywhere else purges the session's cache, since that file
was not written by this code. None of this is reported on screen: none of it
changes what the screen shows, and the reader has nothing to do about it.
## Layout on disk
<cacheDir>/transcripts/
v1/
10.0.2.2_8443/ one directory per server (host_port)
3f2c…/ one per session id
1-1650.rows.jsonl coalesced page: covers seqs 1..1649
1650-2001.rows.jsonl
2001-2400.raw.jsonl a closed live run
2600-open.raw.jsonl the live run
Here 2400..2599 is a gap: the reader was away for two hundred events and the
stream reset. The suffix is the single chunk `2600-open`; the first backwards
page asks the server for `before=2600&after=2399&coalesce=true`, and once a
page comes back with `first == 2400` the suffix runs to seq 1.
Each `.jsonl` is one JSON object per line, oldest first, exactly as the
server sent it. No header, no index: coverage is in the name, order is the
file's, and the seq is in every line.
## What building it changed
Each of these contradicted the plan, and each was found by running it rather
than by reading it. The decisions above are amended in place; this is what
moved, so a reader who remembers the first version knows what to re-read.
- **The probe's equality had a false premise** (decision 3). The server did
not hand out the same line twice the same way. Fixed on the server.
- **A cached page starts anywhere inside the run** (decision 5). Requiring a
chunk boundary would have made the cache save the opening window and
nothing else.
- **The opening window is stored by `append`, not by `storePage`.** The
sketch had `storePage` grow a special case for "this page is the new open
chunk", decided by an implicit condition a raw history page also satisfies.
Appending each line instead is the mechanism that already exists, and the
open chunk stays the one thing that grows.
- **Chunks are read backwards, in blocks, and never whole.** Every question
the cache is asked is about the newest end, and a live run reaches the size
of the conversation — so reading a chunk to answer with eighty lines of it
is the cost the server's own reader was rewritten to stop paying, arriving
on the phone. Damage is therefore noticed when a read reaches it rather
than up front, which is the better time: what is not read cannot be wrong.
- **The stream waits for the opening effect's probe.** The screen lifts
`ready` before the probe returns — that is the point of the cache — so
`ready` stopped being the whole gate, and the stream loop asked the same
question a second time and raced its own answer. Two probes per warm open,
visible in the server's log.
- **`SessionCache` is synchronized.** The stream appends live events from one
IO thread while a reader scrolling back reads pages from another; the open
chunk's name, its end and its writer must never be seen half-rotated.
## What it cost, measured
On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of 505
events (three short exchanges and two 300-delta replies):
- **Reopening it: one request, for one event.** The probe, and nothing else —
including scrolling the whole conversation back to its first line. A cold
open of the same session is two requests and 100 events.
- **A reset after falling 300 events behind costs the gap and no more.** The
window arrived at seq 306, the phone held up to 202, and the first
backwards page asked `before=306&after=201` and came back with **four
coalesced rows** covering 202..305 — against the 104 raw events an
unbounded page would have re-fetched and thrown away.
- **Every chunk is exactly what the server says for the range its name
claims**, checked line by line against `/transcript` for each chunk's own
`before`/`after`/`coalesce`, across a reset and a gap-fill.
- **Nothing about drawing changed**, which is what a cache must not do:
`transcript-bench.sh` before and after, same viewport content and gestures,
p50 16.9ms both times and the transcript's own draw accounting at 0.33ms
against 0.32ms.
Still to measure, in real use rather than here: the size the cache reaches
against `CACHE_BUDGET_BYTES`, and whether the probe's round trip is ever what
a reader waits on.
## Open questions
- **The probe on every reconnect, not only on open?** A file replaced *while*
the screen is open is not made worse than it was, but the server-side check
decision 3 rejects would close it. Decide after measuring how often the
probe's round trip is what the reader waits on.
- **Images.** `SessionImage` fetches bytes from the files route on draw; they
are not part of this cache and are re-downloaded per view. A separate,
simpler cache (a directory of refs, no ordering) if the measurement above
says the images are where the data goes.