50 KiB
Moving the app to Rust
Plan for a native Rust phone app with full feature parity and a shared desktop UI. It uses iris; platform-specific entry points are acceptable, but shared screens, widgets, and styling are not duplicated. The result must stay lightweight and preserve native behavior and performance.
Keep this file current as you work
Keep open work, current design, measured constraints, and dead ends that would otherwise be repeated. Delete completed plans and migration narratives. Name the command and measured value when evidence matters.
Current status
- The framework is decided and built on. iris draws the transcript screen on the desktop, on this checkout's emulator and on Iris's phone.
- P0 (the phone benchmark gate) passed -- both apps ran on her own
phone and the reports are under
docs/bench/. - P1 (session screen parity) is the current work, and is where the next session should start. Its box below has the state.
- The port is one crate under
app-rust/;iris/is only the UI framework. - Open across the rest of the docs:
docs/IRIS_TODO.mdis iris's own list;docs/TODO.mdis the Compose app's.
Desktop and phone share the code
Iris plans to develop a desktop app as well, and asked that most code be
sharable between desktop and phone. The tree already has that
shape -- iris and app-rust's client and ui modules are
platform-free, and src/android/src/desktop are the entry points --
so the rule is about keeping it: a platform module holds only what the
platform forces. Today that is JNI, the IME and insets bridge, the
surface lifecycle and the bench JNI on Android; winit, argv and the
config file on the desktop. What differs is the screen layout, since a phone
screen with a finger and a desktop screen with a mouse want different
arrangements -- a session list beside the transcript rather than a
screen behind it, hover states, keyboard shortcuts. What does not
differ is everything a layout is built from: the widgets (a tap
button, a text field, a list, a card, a tool-call row), gestures,
folding, paging, selection, and the styling -- colours, spacing, type,
the surface ladder -- which is the exact same code on both, never a
desktop palette beside a phone one. Those are written once in a shared
module, with a platform trait underneath when a behaviour genuinely
differs (FocusHost, OpenUrl, and the insets/ime_visible
feed are the existing examples). Two checks before finishing a change
under iris/: does ai-app-desktop still build and run with it, and is
any UI logic newly in src/android that a desktop would also need?
The bench client (app-rust/src/android/bench_client.rs, ~1000 lines) is
the first thing to look at moving, since a desktop bench on the same
fixture is layer 2 of the test rig below.
Three test layers, cheapest first (decided 2026-09-07)
Iris's suggestion, adopted and layered: test at the cheapest layer that
can answer the question, and go up only when it cannot. The emulator
costs minutes a cycle; the desktop window seconds; the headless harness
runs inside cargo test.
-
Headless, in-process, no compositor and no GPU -- the default.
iris::harness(iris/src/harness.rs), plus the fixture crate it opens.Harness::new(size, density)builds anRscwhoseUiowns the retained render state, and a state whoseFocusHost/OpenUrlrecord what the platform was asked for;frame(t_ms)/frames_until(..)run frames on a clock the test owns, andreplay(&TouchScript)feeds a recorded gesture one sample at a time exactly asIrisViewPeer::on_touch_eventreplays Android's historical samples. The recordings are plaint_ms action x yfiles underapp-rust/touch/, andflick-120hz.touchis the phone's own shape: DOWN, four samples 4ms apart, UP, 20ms in total.cd app-rust && cargo testruns in about a second and asserts (a) the flick releases with a real velocity (
List::fling_velocity, which onlyReleased(Some(v))fills), (b) the list travels and settles inside the AOSP spline's ownFlingCalculator::duration, (c) a tap moves nothing and opens no link, (d) a long-press-then-drag leaves selected text and does not pan, and (e) the composer clears a simulated 1000px IME inset (Composer::set_bottom_inset). Each was confirmed to fail without its subject rather than assumed: droppinganimate(id)fromSelectionController::drag-- the phone's own "fling does nothing" defect -- and starting the fling curve at the wall clock each fail only the flick test; flinging onTappedfails only the tap test; a 5sLONG_PRESSfails only the selection test; aset_bottom_insetthat ignores its argument fails only the composer test.What still cannot be answered below layer 3: nothing renders here, so anything about pixels -- glyph rasterisation, the atlas, stale or duplicated primitives, colour, the surface lifecycle, the renderer rebuild -- is invisible to layer 1 and only looked at in layer 2. Frame times are not measurable at either: layer 1 does no GPU work at all and layer 2 runs a debug build on this VM's virtio GPU, so a number from either is not the phone's. Anything JNI (the IME, real insets, the clipboard, battery) is layer 3 by construction: layer 1 records that the platform was asked and layer 2 has no Android platform to ask.
The one exception, added 2026-09-08:
iris/tests/mask_sdf.rsneeds a GPU but no compositor and no window -- it asks wgpu for an adapter, runs two functions lifted out ofshader.wgslitself in a compute pass, and compares the answers with the CPU transliteration iniris_core::render::sdf. It sits insidecargo testbecause what it checks is arithmetic rather than pixels: the fragment stage and the hit test have to agree about where a rounded edge is, and neither layer 1 (which cannot run the shader) nor layer 2 (where a half-pixel disagreement is invisible) can say whether they do. Reach for this shape only when the question is "do these two implementations of one function agree" -- anything about what is drawn is still layer 2. -
A phone-shaped desktop window under headless sway -- for looking.
cd iris && ./run-headless.sh phone --phone --dir ../app-rust --shot /tmp/p.pngAbout 15 seconds warm.
--phonesets the private sway output to 1080x2424@120Hz and exportsIRIS_SCALE=2.55, which reaches iris the wayDisplayMetrics.densitydoes on Android (iris::default::content_scale) -- the desktop backend now lays out in physical pixels with a density instead of dividing into a separate logical space, so both platforms run one path.app-rust'sphoneexample opens the same screen from the same bytes as layer 1 and the Android bench.A gesture on screen uses the same recordings:
./run-headless.sh phone --phone --dir ../app-rust \ --replay ../app-rust/touch/flick-120hz.touch --shot /tmp/p.pngwrites
/tmp/p-before.pngand/tmp/p.pngeither side of the flick; looked at 2026-09-07, the list moved back about seven turns of the fixture and settled.swaymsg seat - cursorcannot drive it, and that cost an hour. This compositor runs the headless backend with no input devices (WLR_LIBINPUT_NO_DEVICES=1,LIBSEAT_BACKEND=noop): the cursor commands all reportsuccessand nothing whatever reaches the client, withswaymsg -t get_seatsshowingcapabilities: 0as the only sign. wlroots 0.19 droppedWLR_HEADLESS_INPUTS, and ydotool's uinput device would be ignored by a compositor that is not reading libinput.iris/rig-input'sreplay-touchuses the virtual-pointer protocol instead, which is a client protocol and needs neither devices nor root, and it parsesiris::harness's ownTouchScript. Two traps inside it, both found by printing winit's events: a button sent in the same frame as the motion that first puts the pointer over the window is dropped (the client sees the enter, the moves and the release, never the press), so the pointer is positioned and left to settle 200ms first; and a leftover window from an earlier manual run tiles beside the new one, halving the width and producing a screenshot that looks exactly like a duplicated- primitive rendering bug --swaymsg -t get_treeandpgrep -af examples/phoneare the check. -
The Android emulator -- platform plumbing and the final pass. JNI, IME, insets, surface lifecycle, the renderer rebuild, and one verification run before a build goes to the phone. Not for iterating on layout.
What has to be reproduced
The app is ~19,000 lines of Kotlin. It splits three ways, and the split is what decides how much of a port is mechanical.
Pure logic with no Compose or Android in it, ~4,500 lines. Api.kt
(1,142), Events.kt, EventStream.kt, Sse.kt, TranscriptCache.kt
(589, touches java.io.File only), TranscriptSource.kt,
MarkdownSyntax.kt, Languages.kt, Highlighter.kt, Ansi.kt,
ResetCountdown.kt, Durations.kt, Sizes.kt, ModelName.kt,
LoadState.kt, ImportableStream.kt. TranscriptUnits.kt and
TranscriptItems.kt (the event fold into rows, ~940 lines) are logic with
a handful of Compose annotations. This is also exactly the code that has
JVM unit tests today. All of it ports directly, and most of it already has a
Rust twin in server/: Events.kt is a hand-kept mirror of
session/driver.rs's enum, the highlighter and the syntax scanner exist on
the server for the explorer, and the cache compares the server's own JSON
lines. Sharing these types between server and app is the single largest
"keep things in sync" win available, and it does not depend on which UI
framework wins.
Compose UI, ~13,000 lines. Screens, dialogs, the transcript list, the markdown renderer's customisations, tool cards, the file explorer viewer and editor. This is the part a UI framework choice is about.
Android platform code, ~1,500 lines, spread over 20 files. Every one of these is a Java-side object that no Rust framework can replace, because Android only offers them as Java classes:
NotificationService— a foreground service holding the/notificationsSSE stream while the app is closed, with its ongoing notification,specialUsetype and thePOST_NOTIFICATIONSrequest.MainActivity— edge-to-edge, theACCESS_LOCAL_NETWORKruntime permission (Android 17),singleTopintent routing foraiapp://enroll, notification taps, and the share sheet (ACTION_SEND, any MIME type).ServerConfig— the bearer token sealed under an Android Keystore AES-GCM key, shared with Dev Updater throughwg-app-link's:link.EnrollmentScanActivity— the in-app QR scanner (zxing, camera).Attachments—ContentResolverreads of shared URIs,BitmapFactorydecode and downscale, EXIF orientation.SessionImage— bitmap decode for produced images.ScrollAnchor,Drafts—SharedPreferences;CrashLog—filesDir.TranscriptCache—cacheDir.DebugStats/FrameStats—Choreographerframe timing and the render report;runtime-tracingnames composables in a system trace.
So "pure Rust" on Android means Rust owns every line of logic and drawing, behind a thin shell of Java stubs, and a packaging step that produces a signed APK. How thin, and whether Gradle is inevitable, are answered below.
How much Java is unavoidable, and why
Rust can call any Android API through JNI (jni crate, with
ndk-context handing over the JavaVM and the Activity): posting a
notification, startForegroundService, the Keystore, ContentResolver
reads, permission requests, WindowInsets, the clipboard. None of that
needs a line of Kotlin. What JNI cannot do is define a class that the
system instantiates by name from the manifest — an Activity, a
Service, an Application, a BroadcastReceiver. Those must exist as dex
bytecode inside the APK before any Rust runs, because the framework
constructs them and only then calls into native code. NativeActivity is
the platform's own stub for the Activity case; there is no
NativeService, and android-view ships its own View subclass for the
same reason.
So the floor is roughly two Java classes of ten lines each: an
Activity and a Service whose lifecycle methods are declared native
and registered from JNI_OnLoad, plus whatever android-view already
provides. Everything they would have done in Kotlin — insets, intent
routing, the SSE follow loop, the notification builder — is Rust reached
through those stubs. Writing the stubs in Java rather than Kotlin drops
kotlinc from the toolchain; javac comes with the JDK Gradle already
needs. Generating the dex from Rust is not worth it: there is no mature
Rust dex writer, and the stubs never change.
Can the APK be built without Gradle?
Yes. An APK is a zip containing a binary-XML AndroidManifest.xml,
resources.arsc, classes.dex, lib/<abi>/*.so and assets, aligned and
signed with the v2 scheme. The tools are aapt2 (manifest and resources),
d8 (Java bytecode to dex), zipalign and apksigner, all in the SDK's
build-tools, none of them Gradle. Three ways to drive them:
- A
cargo xtask(orbuild.rs-adjacent script) that runscargo ndkfor each ABI,javac+d8for the stubs,aapt2 link,zipalign,apksigner. About 150 lines, every step visible, no AGP, no Gradle daemon holding 2.8 GB between builds. The pinned-CA constant becomes abuild.rsreading the samecerts/ca.pempath. - cargo-apk2: the maintained
successor to cargo-apk, and unlike it compiles
java_sources/kotlin_sourcesinto the dex and declares multiple activities and services with intent filters from[package.metadata.android], with per-profile keystores and optionalaapt2. Exactly the shape needed; the question is whether a third-party tool with one maintainer beats 150 lines we own. - cargo-apk / xbuild: unmaintained and
NativeActivity-only. No.
What Gradle would take with it: Android Lint (which found two real bugs
here, but in Kotlin that would no longer exist — with forty lines of Java
stubs there is little left for it to find), manifest merging, R8, and the
generated-source plumbing. What it gives back: one toolchain, cargo
end to end, and Dev Updater keeps calling build-apk.sh exactly as now.
Recommendation: the xtask, with cargo-apk2 read for the details it
already got right (v2 signing, uses-feature, ABI splits).
The behaviours that are hard to get back
Reading the Compose code for what a replacement must be able to express, rather than what it happens to look like:
- The transcript is one selectable body of text. A
SelectionControlleris registered directly on the lazy list, with no selection widget in the layout tree, so a selection runs from a reply into the tool output beneath it. Each parent supplies either draw order or one visual axis for its immediate children; ordering is resolved only when selection queries it. Iris owns the selection handles because its text is drawn into one surface, while the platform supplies the clipboard and related system services. - Rich inline text: markdown with links (one tap detector per text, not a node per link), inline code chips drawn behind the text, tables with wrapping cells and a sideways scroll, syntax-highlighted fences, ANSI colour in tool output, Nerd Font icon glyphs. Needs a text layout engine with spans, not just styled labels.
- A bottom-anchored virtualised list of variable-height rows, paged in
both directions (800-event pages,
HISTORY_SCREENSmeasured in viewports), with a saved scroll anchor per session, "hold the edge nearest the tap" when a row expands (holdTopEdge, done in the layout pass so the wrong frame is never drawn), and rows keyed so that a run of tool calls stays one row while it grows. - The soft keyboard: the composer resizes with the IME, the guard against a stuck inset animation, drafts per session, autocorrect and suggestions from the phone's own keyboard. This is where most Rust frameworks fail on Android today; see below.
- Platform integration through the app model: foreground service, notifications, share sheet, deep link, Keystore, camera, back gesture, edge-to-edge insets, local-network permission.
- Accessibility names on icon buttons, which the bench scripts depend
on (
ui-tracetaps by label). A framework with no accessibility tree also breaks the measuring rig. - Measurable frames: the debug render report, and a way to attribute a frame's cost to a widget on the real phone.
Measurements and constraints
The Android release profile and APK size
Iris asked why the iris bench APK was double the Compose one (20.6 MB vs
10.1 MB). It was almost all libmain.so, built with panic = "abort" and
nothing else. Measured cumulatively, arm64 release:
| profile.release | APK bytes | .so bytes |
delta |
|---|---|---|---|
panic="abort" only (baseline) |
20,678,956 | 18,546,488 | -- |
+ strip = true |
16,435,156 | 14,302,688 | -4,243,800 |
+ lto = "fat" |
15,751,212 | 13,618,744 | -683,944 |
+ codegen-units = 1 |
15,185,204 | 13,052,736 | -566,008 |
+ opt-level = "s" |
13,326,076 | 11,193,608 | -1,859,128 |
+ opt-level = "z" (not adopted) |
12,507,276 | 10,374,808 | -818,800 |
| + platform fonts, no bundled Noto | 9,577,940 | 7,445,472 | -3,748,136 |
opt-level = "z" was not taken: 0.8 MB is not worth the loop
vectorisation on a renderer. Everything else is
app-rust/Cargo.toml's [profile.android-release] -- a profile of its
own rather than release, so the desktop build is not also optimised for
size.
The fling stutter and what a frame report cannot say
Iris, from her phone: "I'm noticing some stuttering when flinging in particular. Harder to notice with my finger directly moving the scroll." Her report had the fling phase at 3396 frames over 33.0s -- 103fps on a 120Hz screen -- with p50 6.3ms and 13.4% "late".
The report was not measuring what its own labels claimed. Three things came out of chasing it, and the first two are corrections to the instrument rather than to the renderer:
-
The swapchain acquire was counted as iris's CPU work.
AndroidRenderer::drawtimedqueue.submit+present()and called everything before itredraw_to_submit, butget_current_texture-- which blocks until the compositor frees an image -- sits in that span. An app comfortably ahead of the display spends most of every frame there, so a healthy fling read as several milliseconds of iris being slow. A frame is now three measured parts (FrameParts:build,acquire,submit), per phase as well as per run, because they do not divide the same way in every phase. -
Nothing could say a frame was never produced.
latecounts frames that cost more than a budget, which is not the thing a reader sees: a frame that is late but drawn shows up on the next vsync, while a frame that never happens leaves the previous one on screen for two refreshes.PhaseStats::missedcounts vsyncs nothing was drawn for, from the gap between consecutive frame times. -
The frame loop asked for its next frame after doing the work.
Choreographer.postFrameCallbackschedules for the next vsync after the call, so any frame whose work ran past the vsync boundary registered too late for the next one and got the one after -- one frame over budget silently cost a second frame as well. It is asked for immediately aftertick_animations, before the layout and the draw.
And one that is about the animation rather than the report: the fling
was advanced on Instant::now(), not on the vsync the callback carried.
do_frame's frame_time_nanos was discarded. Frames are presented on
an even cadence whatever clock they are computed on, so sampling the
spline at "whenever the callback got to run" moves the content by an
uneven distance every frame -- a shimmer with no frame late enough to
appear in any report, and it is exactly the asymmetry Iris described,
since a drag's positions come from the finger's own timestamped samples
and never had it. sense::PointerClock is now sense::DeviceClock and
the view keeps one, anchored by whichever of a touch or a frame
arrives first, so a fling is advanced on the clock its velocity was
measured on.
What the CPU side is not: scripts/rigs/ui-profile's frame_profile.rs (AGENTS.md's
rig list) puts iris's own per-frame work during a warm fling at p99
0.26ms, with only one frame in six laying anything out at all. The
multi-millisecond spikes are first-pass only.
The result, from Iris's phone the same day: "now THAT is smooth. I couldn't actually see any lag myself." With three corrections to what the report meant, found by reading that run against the bench's own timings:
- The frame rate never was the problem, and the first reading of it was wrong. "103fps on a 120Hz screen" divided the fling phase's frames by its whole duration, which includes sixteen deliberate 300ms rests. Both runs sustained ~120.3fps through the motion itself. So the callback ordering was not costing frames -- what changed is the clock, which moves no frame count and is the whole point: an uneven sample of an even cadence cannot show up in any frame-time percentile.
missed vsyncscounted idleness. Every gap was treated as cadence, so the bench's own pauses read as stutter: 276 for sixteen 300ms rests, 2410 for twelve hundred 50ms keystroke gaps, 821 for four hundred 50ms stream gaps -- each within a few percent of the arithmetic. A gap now measures anything only if the frame before it had asked for another.latecounted the vsync wait as cost. A well-paced loop spends each frame blocked in the acquire, so its total sits at exactly one refresh period and every frame lands on the budget boundary. It is judged onFrameParts::work-- the total minus the acquire -- now.- The refresh rate is the larger of what the platform claims and what
the run sustained, because each can only be wrong one way.
Display.getRefreshRate()answered 60 for a run that drew 3405 frames in 33.1s, since a phone that varies its rate answers with whatever mode it is in when asked. AndFrameReport::sustained_frame_hzis a floor: an app that cannot keep up says nothing about the panel. The first version of it took the fastest tenth of the gaps rather than the sustained rate and reported 88Hz for this repo's 60Hz emulator, whose app manages 51 -- a budget no frame there could meet, invented out of the app's best moments, and caught only by running the corrected report on the emulator before shipping it. The two are printed together whenever they disagree.
The signature of the fixed loop, from that run: build p50 0.4ms, acquire p50 5.7ms, submit p50 1.7ms -- four tenths of a millisecond of
work and the rest of the refresh period spent waiting its turn.
Streaming frame time
Measured after the fling was fixed, and it is not where it looks.
frame_profile.rs's stream run: folding an arriving event is 0.35ms and
applying the diff to the widget tree is 0.41ms, while the frame that
follows is 3.86ms on this desktop and 9.5ms of build on Iris's phone --
over a 120Hz budget on its own. 401 streamed events move the item count
from 652 to 654, so nearly every one is a delta into the same row: the
cost is re-laying out and re-shaping one growing markdown message on
every delta.
fold_event's items.to_vec() per event was the hypothesis -- it is the
exact shape of the Compose lesson in AGENTS.md's "Things that have
bitten" -- and measuring it is what ruled it out.
Incremental text shaping
Iris asked to investigate incremental text rendering and hoped parley
supported it. It does not, by design. The crate's own docs: a
Layout "supports re-linebreaking and re-aligning many times... but if
the text content or the styles applied to that content change then a new
Layout must be created". Its LruCache caches harfrust's per-font
shaper data, instance and plan -- not shaped runs -- and its own
PlainEditor::update_layout rebuilds the whole layout from the whole
buffer on every keystroke. So there is nothing to adopt, and adding
it would be upstream work in parley.
And the app already does the thing incremental layout would buy.
RowBlocks::apply_delta keeps one Text per top-level markdown
block and re-shapes only the block a delta landed in; re-splitting the
markdown to find that block is 18µs at 18,000 characters and comparing
the blocks is 470ns. Neither is the cost.
The 9.5ms is a bench-fixture artifact. Measured with
frame_profile.rs:
- Re-shaping a block is linear in its length -- ~0.23ms per 1,000 characters on this desktop, so a message grown to 17,600 characters costs 4.1ms on its last delta and 842ms of shaping over the whole reply.
- The fixture's streamed message is 14,888 characters in one block -- a synthetic run-on paragraph with no blank line in it, so every delta reshapes all of it. That is the whole of the frame: 3.5ms of the measured 3.86ms.
- Real replies are not like that. Over 7,706 top-level blocks from 3,675 real assistant messages on this machine (block lengths only; no content left the machine): p50 147 characters, p90 449, p99 836, largest 1,580, and nothing above 4,000. Code fences are smaller still -- 170 of them, p50 126, largest 589.
- At those sizes a live reshape is 48µs (p50), 208µs (p99) and 372µs (the largest block ever seen), or roughly 0.12-0.93ms on the phone. Comfortably inside a 120Hz budget, with no incremental anything.
So: incremental text is not worth building -- and Iris agreed, with the fixture changed instead (2026-09-09: "let's switch to new lines for the test, and also let's keep the single line around for stress + could be something to try to optimize later"). What landed:
- The streamed reply gets a blank line every 4-12 deltas, so it is 53 blocks with a longest of 502 characters instead of one of 14,888. The streaming frame went from p50 3.86ms / p90 8.65ms / worst 10.95ms to p50 2.20ms / p90 5.90ms / worst 8.78ms here.
- The run-on message is kept as the first two backlog events, sized just
under
text_cap's 16 KiB so it draws in full. The streaming pathology is kept inframe_profile.rsinstead of the fixture, because it needs a growing block and iterating on it there costs a second rather than a two-minute phone run. - Adding it is purely additive: the random state is saved and
restored around those two events, so every other backlog event is
byte-identical. That is not cosmetic --
phone_screen.rs'sa_long_press_and_drag_selects_textreplays a real recording at (300, 1000) and failed the first time round, when the insertion shifted what was under it. BACKLOG_COUNTis 3202 now, ingenerate.py,fixture.rsandBenchFixture.kt. The split is by line index, so a stale copy opens a different half of the file.
The cap does not save a streamed reply, and this is worth knowing
before optimising anything here. Iris asked whether the newest message
caps: it does not, deliberately -- row::build_row's cap is false
for the live tail because a row that grew while capped would appear to
stop growing, and a reply that grows past the cap never gets caught
either, since it grows through apply_delta. So a streamed block's
shaping cost has no ceiling: at the measured ~0.23ms per 1,000
characters (about 2.5x that on the phone), a 50,000-character block
would be ~29ms per delta and a 100,000-character one ~58ms. Real replies
do not do this, which is why it is not urgent; nothing stops one doing
it, which is why the stress case is kept.
What the remaining streaming cost is, and is not. With realistic blocks the reshape is no longer the cost: layer 1's frame went to p50 2.20ms, spread over frames that added a block (p50 3.56ms, 56 of 401) and frames that did not (p50 1.94ms). Folding is 0.12ms and applying the diff 0.35ms.
But the emulator's stream: build p50 did not move -- 10.4ms before
the fixture change, 10.5ms after -- while layer 1's CPU frame nearly
halved. So most of a streaming frame on a real GPU path is something
layer 1 builds and never uploads, and therefore cannot time. The
candidate, and the arithmetic behind it:
- The screen holds 11,568 primitives by the end of the stream phase.
UiRenderNode::updatere-uploads the entire instance and primitive arenas wheneverprimitives.updatedis set, which a text change sets every delta -- about 370 KB per delta at 32 bytes an instance, before the primitive data itself.ArrBuf::updatealso recreates the buffer whenever its length changes, which adding glyphs does on nearly every delta, and a recreated buffer means a fresh bind group too.- The fling phase is the control that makes this convincing: it moves
the same 11,568 primitives every frame through
move_offsets-- a small buffer, no arena rewrite -- and itsbuild p50is 0.4ms against streaming's 10.5ms, on the same screen and the same content.
So the next thing to look at for streaming is uploading only what changed rather than the whole arena, not anything about text. Splitting the reply into blocks was still right -- it is what makes the fixture representative, and it halved the CPU half -- but it was never going to move this, and it slightly increases the primitive count.
Arena delta uploads
Done, and measured by scripts/rigs/ui-profile's arena_churn -- see
AGENTS.md's entry for the rig and the numbers. The arithmetic above was
right about the symptom and wrong about the cause being the upload
strategy alone. Three things, in the order they had to be fixed:
ArrBufreallocated on every length change, and a fresh buffer's contents are undefined, so a partial upload could not have been correct in the first place. It has a capacity now: geometric growth, never shrinking, andupdatesays whether theBufferidentity moved so a caller can rebuild its bind group and force the whole range dirty. This alone took the glyph array from 95% re-uploaded to 3%.- A redraw freed its primitives and pushed new ones. Freed slots are
not reusable until the end of the frame (a layer's draw order still
names them), and nested provisional layout meant the arena's high-water was
the transient push count: 17 million pushes across 401 deltas, and
127,443 slots for 11,569 live primitives, growing linearly with the
transcript. A redraw now gets its old handles back as a recycle pool
(
Painter::take_recycled,Primitives::recycle) and writes into the slots it already holds. The arena is exactly the live count now, and the CPU frame fell from p50 2.20ms to 1.39ms as a side effect, since the freeing and draw-order renumbering went away. - Nothing tracked which entries changed.
util::Dirtyis a bitset per uploaded array, coalesced into ranges at a 1 KiB gap. Marking is O(1), and the read-back is one word per 64 entries. Amin..maxspan was rejected on measurement (a frame's changes land in 5-20 scattered runs, so a span is nearly the whole buffer) and so was aVecof indices (thousands of marks per frame would mean an allocation and a sort).
The trap that only the rig could have caught: writing an entry is not the
same as changing it. Recycling rewrote every glyph of every moved row with
identical bytes, marking 73% of the glyph array against 0.6% genuinely
changed. PrimitiveVec::set and Primitives::set_instance compare before
marking. Layout can also write a provisional instance and restore it within
one frame; Primitives remembers the pre-frame bytes and cancels that dirty
bit when the GPU-visible result is unchanged. arena_churn prints both
numbers so either gap cannot reopen unnoticed.
Layout has no measurement mode. A widget is drawn provisionally only
when its size cannot be known yet, and that retained drawing is moved into
place. Widget::size_hint(axis) lets context-free wrappers such as Sized
report an exact Len; a debug assertion compares every hint with the real
draw result. If final allocation changes a child's size, Painter::place
redraws it in that box. Otherwise placement is one move-offset write.
Measured over the fixture's 401 streamed events, streamed-frame CPU p50 is 0.12ms, from 1.18ms before this layout change. Arena size and upload floors are unchanged.
Pinned growth now uses the same subtree translation as scrolling. A
container can retain a child-coordinate move slot through
Painter::set_child_offset; LazySpan keeps retained rows in stable local
boxes and changes that one slot when its anchor moves. It still walks the
visible run to virtualise it, but unchanged rows no longer acquire new
absolute primitive regions. Over the fixture's 401 streamed events, instance
upload is 1.1% against a 1.1% floor, from 71.9% against 71.8%; median
instance bytes per frame are 1,488, from 176,496. This is framework
layout/rendering behaviour and the transcript screen contains no special
case for it.
The Android release profile uses opt-level = 3
The table above was measured in bytes only. "s" costs the loop
vectorisation and inlining a renderer runs on: over the same warm fling
eight times, iris's own per-frame work is p90 0.15ms / p99 0.42ms at
"s" against p90 0.09ms / p99 0.26ms at 3. The arm64 release APK goes
from 9,745,704 to 11,542,646 bytes (+1.8 MB) -- the same trade the table
refused for "z", one level further up. Iris raised it herself
("I'd make sure it's in release mode"); the build always was, and this
was the part of "release" that was not about speed.
Platform fonts
Iris: "remove the font for now; just match what compose does." The
Compose app takes body text from FontFamily.Default and code from
FontFamily.Monospace and ships no text font, only its Nerd Fonts icon
subset. So TextData::register_bundled_fonts, the six include_bytes!
Noto constants and iris/core/assets/fonts/'s .ttfs are gone.
The reason this works at all: FontContext::new() was already finding the
platform's fonts underneath the bundled ones -- fontique's
CollectionOptions::system_fonts defaults to true, with a real backend
on both platforms iris ships on (fontconfig on Linux, /system/fonts +
/system/etc/fonts.xml on Android). The icon font is the opposite
case and is still bundled: a small, closed set of codepoints no system
font is guaranteed to have (AGENTS.md's "Icons").
Still unverified, and it is the half that can fail (review R6,
2026-09-07): the bundled fonts originally existed because "bold spans on
a real phone rendered as blank gaps of the correct advance width", and
the replacement was checked with CJK and emoji on the desktop. The
fault was Android's font enumeration resolving a weight/style, so the
desktop cannot answer it. Before the next phone build, look at a bold run
and at CLOSED_MARK/OPEN_MARK/UP_MARK (U+25B8/BE/B4) on Iris's own
device; the emulator's font set is not evidence for hers.
The port, in order
Every screen is a module under
app-rust/src/ui, which holds a Screen enum and a back stack -- the
direct equivalent of AppRoot.kt's when and MainScreen.kt's tab
enum -- with each Compose screen becoming one iris::widget subtree.
src/desktop and src/android are thin entry points that call into it,
the way AppRoot/MainActivity today call into Compose screens they do
not otherwise own. Platform-only code (the notification foreground
service, the share target, the QR scanner, the Keystore-sealed token,
deep-link enrolment) stays in src/shell + app/shellApp, since none of
it is a screen ui could draw.
Order is by risk to the daily-use path, not by screen count: the session screen is what the app is for and where every hard behaviour (paging, cache, keyboard insets, selection) already lives, so it goes first and on the phone as reachable code as soon as possible, before the lower-risk screens.
Every step below assumes the app/ui-sandbox.sh fixtures (AGENTS.md's
"The rigs") and the this-machine-android skill's facts (per-checkout
AVD, ui-trace by accessibility name, GrapheneOS phone quirks, the
adb shell quoting traps) apply unchanged -- read that skill before
running any pass condition below that touches an emulator or a real
device.
Bench-only cleanup still open: the diagnostics report pane draws over
transcript rows. REPORT_MAX_HEIGHT_DP constrains its claimed height, but the
pane is neither masked nor scrollable despite its construction comment saying
it is both. This is an app-rust defect, not an iris framework item.
-
P1 — session screen parity. Continue in this order: - [ ] P1c — history paging and jump-to-latest. Wire
client::transcript_sourceintosrc/ui: the opening page, paging back on scroll with the cushion measured in on-screen viewports (HISTORY_SCREENS, IRIS_TODO "Build (for the port)"), theNothingLoaded/empty/error states drawn distinctly (UI_RULES: design the unknown state first),join_pagesat each seam, and a jump-to-latest control that pins to the newest end. Pass condition: the P1 pass condition below, againstui-sandbox.shwithAI_SANDBOX_BIG_MBand--delay. - [ ] P1d — images, the session settings dialog, attachments, usage bar.SessionImagethumbnails (the scaled image widget), the existing overlay facility andSessionSettingsDialog/UsageDialog,PendingAttachmentsover the attachments route (api.rsgap),SessionUsageBar(the gauge widget). - [ ] P1e — keyboard and insets behaviours from AGENTS.md's "Things that have bitten", re-verified on the phone build: composer never left floating after the keyboard closes mid-stream,adjustResize+ edge-to-edge together, one recomposition-equivalent per keyboard toggle (theiris insets:log line count).The remaining screen work is history paging and jump-to-latest, the session settings and usage dialogs, images and composer attachments, and the keyboard/insets behaviours AGENTS.md's "Things that have bitten" names (the floating-composer bug, `adjustResize`, the `imePadding`-vs-raw-inset rule). This is the highest-risk step: it is the screen the app is used for, every hour of the day. **Kotlin it replaces**: `SessionScreen.kt`, `TranscriptList.kt`, `SessionSettingsDialog.kt`, `ToolInput.kt`, `ToolRows.kt`, `AskQuestion.kt`, `Compaction.kt`, `SessionUsageBar.kt`, `PendingAttachments.kt`, `Attachment.kt`, `Attachments.kt`, `SessionImage.kt`, `MemoryNote.kt`, `PeerMessage.kt`, `RawBlock.kt`, `CodeFence.kt`, `MarkdownLinks.kt`, `MarkdownPieces.kt`, `Markdown.kt`, `Bubble.kt`, `ScrollAnchor.kt`, `Drafts.kt`, `UsageDialog.kt`, `Chevron.kt`, `Dividers.kt`. (`src/ui` already covers the row/markdown/selection/composer core these sit on top of or beside.) **`app-rust`'s `client` needed** (`CLIENT_CORE.md`): the paging half is ready — `transcript_source`, `join_pages` with seam healing, and `markdown_blocks` are all ported. The remaining client gap in P1 is the attachments route (`/sessions/{id}/attachments`), needed by P1d's `PendingAttachments`/`Attachment`. **iris widgets missing, → `IRIS_TODO.md`'s "Build (for the port)" section**: the distance-to-unloaded-edge query P1c needs for its viewport-sized history cushion; per-range text backgrounds for inline code; a fitted image widget; and a horizontal gauge for `SessionUsageBar`. The settings and usage dialogs can use Iris's existing local overlay hosts. Tappable links already exist. Row accessibility names are app content applied through iris's existing `.label()` API, not a missing framework widget. **Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by `ui-trace record --do "tap '<label>'"` — a session with the big transcript (`AI_SANDBOX_BIG_MB`), a paused/slow-spawning one (`AI_SANDBOX_SPAWN_DELAY`), and `--delay` on the server — exercising the four states UI_RULES.md says to design first: unknown (a page that hasn't loaded), empty (a session with no messages yet), error (a failed send/interrupt), and too-long (the big transcript, paged). Re-take the I5 `FrameReport` (`iris frame report` in logcat, same as I5's box) once this screen has real paging and compare it against I5's own numbers, not against Compose's — the three measurement sources still are not comparable. -
P2 — the shell merge and a real phone install. Merge this screen's cdylib into the E3/E5 shell (
src/shell+app/shellApp) behind the same feature-flag pattern I5 used to extendsrc/android(decided 2026-09-05), so there is one app — notification service, share target and the real screen — rather than a demo shell and a service shell side by side. Package withcargo xtask apk(E5) and get it onto the real GrapheneOS phone, not just the emulator:arm64-v8ais the ABI that matters there (the emulator here is x86_64), and thethis-machine-androidskill's facts apply for the first time in this port — no System Tracing on that phone (frame numbers have to come fromFrameReportitself), the local-network permission is required there even though AOSP's docs say VPN traffic is excluded, andui-trace/adbtarget this checkout's own emulator by default so a real-device command needs-s <serial>explicitly.**Kotlin it replaces**: nothing further than E3 already did (`Notifications.kt` → `notifications.rs`, `Share.kt` → `share.rs`, `ServerConfig.kt`'s Keystore half → JNI calls into `wg-app-link`) — this step is wiring P1's screen in as the shell's real content instead of E3's placeholder, plus getting a signed APK onto a physical device for the first time in this port. **`app-rust`'s `client` needed**: none new; E3 already covers what the shell itself needs. Attachments (P1's gap) matter here too if a real photo share is exercised. **iris widgets missing**: none — this step is integration, not new widgets. **Pass condition**: `cargo xtask apk`, install on the real phone over adb, enroll via the deep link, background the app and get a real notification, share a text snippet into a session, and confirm `ui-trace` can still find controls by name on real hardware (accessibility names are not guaranteed to survive a real device's TalkBack/AccessKit wiring the way they do in the emulator — this is the first time that gets checked for real). -
P3 — root tabs.
Screen/MainTabinsrc/ui: the sessions list, import, models and setups tabs, plus spawn and the app's one level of back-stack navigation (AppRoot.kt'swhen).**Kotlin it replaces**: `AppRoot.kt`, `MainScreen.kt`, `SessionListScreen.kt`, `ImportScreen.kt`, `ModelsScreen.kt`, `SetupsScreen.kt`, `SpawnScreen.kt`, `BusyItem.kt`, `UniqueItems.kt`, `SessionAlerts.kt`. **`app-rust`'s `client` needed, not yet covered**: setups/machine/provider discovery, the models routes (`/models*`, HuggingFace browsing and downloads), and importing (`/setups/{id}/importable*`) — all three listed "not covered" in `api.rs`'s table and none started; each is real work, not a stub, per `CLIENT_CORE.md`'s own caveat. **UI still needed**: `BusyItem` is app-specific appearance — a row dimmed, drained of colour and labelled with the operation in progress without blocking the list's scroll — so it belongs in `src/ui`, not iris. `uniqueItems` is app logic there too. Iris itself still needs a toggle switch for the delete-with-`deleteForeign` flow. **Pass condition**: `ui-trace` tap-by-name on all four tabs against `ui-sandbox.sh`'s fixtures; the two-copies-of-one-session-id fixture (AGENTS.md's "Importing") does not crash the list — this is the regression `uniqueItems` exists for and it must be exercised here, not assumed; the delete dialog's paragraph reads correctly both with and without `deleteForeign` toggled (its own text, not appended, per AGENTS.md). -
P4 — file explorer. The viewer, the editor with its
EDIT_LIMIT, and the 409 conflict.**Kotlin it replaces**: `FilesScreen.kt`, `FileViewer.kt`, `FileEditor.kt`, `FileLines.kt`. **`app-rust`'s `client` needed, not yet covered**: `/setups/{id}/dir|file` — not in `api.rs`'s covered list, real work, port first. **iris widgets missing**: nothing beyond what P1 needs (a virtualised line-numbered text view is `iris::widget::List` reused, per I3's box) — the open question is whether the editor's `BasicTextField`-equivalent cost (`docs/EXPLORER.md`'s "what the measurements said") reproduces in iris's `TextEdit` at the same `EDIT_LIMIT`, which this step has to re-measure rather than assume. **Pass condition**: `app/ui-sandbox.sh`'s `~/files` fixture tree (empty dir, tab/apostrophe names, binary, over `FILE_LIMIT`, `chmod 000`, symlinks good and broken, one source file per language, `edit-32k.rs`/`edit-128k.rs`/`big-source.rs`) driven by name; the 409 reproduced by editing the file on the machine between opening it and saving, per AGENTS.md's own recipe. -
P5 — settings, enrollment, notifications permission.
**Kotlin it replaces**: `SettingsScreen.kt`, `ServerConfig.kt`'s remaining non-Keystore parts, `DebugStats.kt`, `FrameStats.kt`, `CrashLog.kt`. The QR scanner (`EnrollmentScanActivity`, in `wg-app-link`) is platform-only and is **not** replaced — it stays a Java/Kotlin activity per decision 1 above, called into from `src/ui` the way it is called into from Compose today. **`app-rust`'s `client` needed**: none new — `config.rs`'s `EnrolledServer`/`parse_link` already cover the deep-link half; the Keystore half stays the JNI call E3 already wired. **iris widgets missing**: none identified yet — a plain form screen. **Pass condition**: enroll via the same `aiappshell://enroll?...` link `ui-sandbox.sh`'s banner prints; the local-network-not-allowed banner (AGENTS.md's standing-condition text) reads by name when the permission is off; `POST_NOTIFICATIONS` request flow checked on the real phone from P2, not just the emulator. -
P6 — desktop parity. Root tabs, explorer and settings on
src/desktop, matching P3–P5 there. Not a Kotlin replacement (the desktop app has no Compose original) — this is closing the gapE4deliberately left (session list + transcript only).**`app-rust`'s `client` needed**: the same P3/P4 gaps, once closed there. **Pass condition**: `run-headless.sh` screenshots of each tab and the explorer against `app/ui-sandbox.sh`, the same way E4's did. -
P7 — the switch of
ai-app's main. Pointai-app's production Android build atsrc/ui/src/shellinstead ofapp/androidApp; decide then whetherapp/androidAppstays as a reference or is retired — a load-bearing decision (AGENTS.md's "ask before changing load-bearing decisions") to bring to Iris rather than make here.**Pass condition**: the full set of pass conditions above, re-run once more against a real `ai-server` (not the sandbox) on a real phone, side by side with the Compose build until it holds.
One app crate
The Rust client is one ai-app crate in app-rust/: platform-free code
is under src/client and src/ui, while src/desktop, src/android, and
src/shell contain the platform entry points. The fixture is behind its
own feature so its 1.9 MB include_str! does not enter ordinary phone
builds.
iris/ now holds core, macro, the iris crate, tabs-ui and
rig-input — framework only, with no mention of a session, a transcript,
a setup or a server anywhere in it.
src/client must not depend on iris. Features select the crate's face:
screens for UI builds, shell for the Compose shell bridge, and bench
for the fixture. The Android faces both produce libai_app.so.
event-model remains separate because both the server and client depend
on that wire contract. Iris remains a separate UI-framework workspace and
must contain no product concepts.
Build constraints
- The rolling nightly setting is per directory. The toolchain files in
app-rust,iris, andscripts/rigs/ui-profilemust stay synchronized. - The Android release profile is
android-release, notrelease. The aggressive settingsiris/android-apphad (panic = "abort",opt-level = "s", fat LTO) would otherwise apply to the desktop build too, which is a testing surface.build-apk.shpasses--profile android-release/--profile android-dev. iris/run-headless.sh --dir DIRselects the workspace containing the example; it defaults toiris/.- Not renamed, deliberately: the Android application id and Java
package are still
dev.iris.android.demoand the label is still "iris android-view demo", both now misleading. Changing them changes the app's identity on Iris's phone (a side-by-side install rather than an upgrade) and theDevLogProviderauthority Dev Updater reads, so changing them requires an explicit migration decision.