Author SHA1 Message Date
iris 599d33287c Add scoped overlay hosts 2026-09-10 18:49:03 -04:00
iris b53a17c436 Require commits for completed work 2026-09-10 18:41:56 -04:00
iris de92fccba5 Add retained paints and shared text selection 2026-09-10 18:35:24 -04:00
iris 25370731d0 Prune commentary and stale Rust port notes 2026-09-10 00:44:13 -04:00
iris 5428cd75c9 Prune Iris TODO and prioritize color correctness 2026-09-09 23:35:12 -04:00
iris 8c6e2ed9cf docs: record final phone benchmark comparison 2026-09-09 22:57:50 -04:00
iris 4bc23172fd Make Iris layout dependencies explicit 2026-09-09 22:35:03 -04:00
iris e5fee03da8 Stop composer layout recursion on spaces 2026-09-09 19:54:53 -04:00
iris 5ece49b8d9 Settle growing layout branches in one frame 2026-09-09 16:54:39 -04:00
iris e212ed8d02 Document separate benchmark publication repo 2026-09-09 16:23:38 -04:00
iris f5183af306 Expose Iris benchmark in Dev Updater 2026-09-09 16:19:46 -04:00
iris fffed42f9e Move LazySpan rows through one retained offset 2026-09-09 16:15:09 -04:00
iris 0aa03cf621 Redesign span layout around retained placement 2026-09-09 15:11:57 -04:00
iris ae0af8f5e3 iris: ScrollArea measures its content instead of drawing it twice
A container that probes a child's size was still doing it with a real
draw, so `Painter::measure` existed and almost nothing used it. Tracing
every draw of one streamed frame: 1,083 `Widget::draw` calls over 113
distinct widgets, the worst drawn 11 times at nesting depth 7-8, every
one of them mode `Draw` and none of them dirty. The cache was working --
each of the 11 was offered a genuinely different region, alternating
between an oversized probe box and a real one.

ScrollArea::draw was one of the two sources. It drew its whole content
at a box built from a stale hint, read the length back, corrected the
scroll position, and drew the content again where it belonged. The first
of those is now a measurement.

The other half is the measure fast path. A widget's reported size is a
function of its own state and the size it was offered, not of where it
was offered -- so an undirtied widget already drawn at a region of this
size has already answered, and `active.size` is the answer. This is the
same assumption `mov` makes one branch further down (same offered size,
therefore identical output, therefore a translation); it is only stated
as a size here rather than acted on as a move. Without it a measurement
costs a full recursive walk, which is what made the nesting compound.

A measurement now also peeks at the redraw mark instead of consuming it
-- it is not the redraw the mark asked for, and swallowing it would
leave the widget stale until something marked it again.

453 draws from 1,083, and the streamed frame is p50 1.18ms (from 1.22ms,
and 2.20ms before this run of work). The headless phone render is
byte-identical to the previous commit's on the real GPU.

What is deliberately NOT here: the same change to `Span::draw`'s phase 1,
which is the remaining 2x and which moves the layout by a few pixels.
The layout stays intact -- it is a position difference, not a broken
frame -- but which of the two is correct was not established, and the
suspicion (that phase 2 now takes `mov`, and `mov` accumulates deltas
where a redraw recomputes) points at a bug in `mov` rather than in
`Span`. Written up in docs/IRIS_TODO.md with Iris's target shape for
`Span`: no probe phase at all for `abs` children, and a `rest` child
forcing a reposition pass rather than a redraw.
2026-09-09 12:08:20 -04:00
iris 18c5f9aaac iris: a measurement is a mode on the painter, not a discarded draw
Painter::draw_twice(child, first, |used| second) becomes Painter::measure
plus an ordinary draw. Iris's objection was the shape it forced on the
caller rather than the two draws themselves: the arithmetic that picks
the real region had to happen inside a closure, and anything it wanted to
keep came back out through a captured &mut. LazySpan::place was the only
caller, and it now reads as the three statements it is.

DrawMode::Measure is that draw with everything it writes switched off --
no arena slot, no mask, no move slot, nothing left in `active`, nothing
marked dirty. Only the returned Size survives, and the widget is left
exactly as it was, so the real draw that follows is an ordinary first
draw or redraw. That last part is load-bearing: a measurement that left
an ActiveData behind would let the following draw hit draw_inner's
"already at this region" fast path and return having drawn nothing.

A measurement also does not consume a redraw mark, since it is not the
redraw the mark asked for, and it takes none of the fast paths, since
"already drawn here" cannot report a size.

Every Painter method that writes now returns early on the mode -- a
widget's own draw never checks, which is the point. A debug_assert at the
end of draw_inner catches one that forgot, because the failure otherwise
is a single leaked primitive per measured widget per frame, which a
screen redrawn every frame turns into an arena that grows without bound.

What this is worth, and what it is not. The amplification it applies to,
measured on a streamed frame: 1,083 Widget::draw calls over 113 distinct
widgets, with the worst drawn 11 times at nesting depth 7-8 -- it is not
two draws but two to the power of how many measuring ancestors a widget
has. Only the writes go away; the walk and the region arithmetic still
happen 11 times, and removing those needs a size answerable without a
draw, which LAYOUT.md section 5 rules out. Streamed frame p50 1.39ms ->
1.22ms, p99 4.75ms -> 3.58ms. The upload numbers do not move, because
slot recycling had already made the discarded writes free in arena terms.

Also extracts move_slot_for from draw_inner, since measuring must not
allocate one and the reuse-in-place rule wanted saying once.

Verified: run-tests.sh, iris's suite, clippy and rustfmt clean, and the
headless phone render is byte-identical to the previous commit's on the
real GPU (Venus, RX 7900 XT -- checked, not llvmpipe).
2026-09-09 11:51:33 -04:00
iris a428cba41a iris: record the streamed-row redraw as an open item
arena_churn says a streamed frame uploads 72.7% of the instance arena
and that this is the floor, against 3.3% for a fling over the same
content -- so the rows are being redrawn where a scroll would write one
move_offsets delta. The upload half landed in 3c7d3db; this is the
layout half, with the measurement, the control that makes it convincing,
and where to look first.
2026-09-09 11:34:30 -04:00
iris 3c7d3db370 iris: the arenas upload deltas, and stop being 11x bigger than the tree
Changing any primitive re-uploaded every primitive. Measured over the
bench fixture by the new arena_churn rig: 758 MB across a fling and
1.2 GB across 401 streamed deltas, p50 3.0 MB per streamed frame.

Three separate things were wrong, and only the first is what it looked
like from the outside.

ArrBuf reallocated on every length change. A fresh Buffer's contents are
undefined, so adding one glyph -- which a streamed reply does constantly
-- forced a full rewrite, and no partial upload could have been correct
in the first place. It has a capacity now, growing geometrically and
never shrinking, and update() answers whether the Buffer identity moved
so a caller can rebuild its bind group and force the whole range dirty.
That alone took the glyph array from 95% re-uploaded to 3%, and stopped
primitive_group being rebuilt on every frame the arena changed.

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 Painter::draw_twice is how a container learns a child's
size, so with containers nested the arena's high-water was the transient
push count rather than the live one: 17 million pushes across 401
deltas, 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 pool is consumed in order and whatever the draw
does not claim is freed when it ends. The arena is exactly the live
count now. The CPU frame improved with it, from p50 2.20ms to 1.39ms on
the stream run, because the freeing and the draw-order renumbering went
away.

Nothing tracked which entries changed. util::Dirty is a bitset per
uploaded array, coalesced into ranges at a 1 KiB gap. Marking is O(1)
and allocation-free; reading it back is one word per 64 entries. Both
alternatives were measured and rejected: a min..max span is nearly the
whole buffer, since a frame's changes land in 5-20 scattered runs, and a
Vec of indices would mean an allocation and a sort per frame at several
thousand marks. It replaces Primitives::updated -- one bool that covered
the instances and the per-primitive data together, so rewriting a rect's
region re-uploaded every glyph -- and TrackedArena::changed.

The trap only the rig could catch: writing an entry is not changing it.
Recycling rewrote every glyph of every moved row with identical bytes,
marking 73% of the glyph array against 0.6% genuinely changed, because
what moves is the instance's region and not the glyph. PrimitiveVec::set
and Primitives::set_instance compare before marking.

Every array now uploads within a hair of its floor: fling instances 3.4%
against 3.3%, fling glyphs 0.9% against 0.8%, stream glyphs 0.6% against
0.6%. Stream instances are at 72.7%, which *is* the floor and is a
layout question rather than an upload one -- the list is pinned to the
newest end, so a growing reply moves every row, and that should be one
move_offsets write rather than a redraw. Noted in RUST.md as the next
thing.

Also: draw_inner's four old_* parameters become one Retained struct, so
the recycle pool is a field rather than an eleventh positional argument
next to three others of the same shape; and free_primitive is the one
place a slot and its draw-order position are retired together.

The rigs move to scripts/rigs/ui-profile, a crate of their own so a
rig's dependencies stay out of the app's -- arena_churn needs bytemuck,
which nothing in ai-app does. arena_churn prints floor, uploaded and
whole side by side per array, because any two of those alone are
misleading and the 122x over-marking above was invisible until all three
were on screen together.
2026-09-09 02:14:51 -04:00
irisandClaude Opus 5 77cee6a8fa The bench fixture streams a reply shaped like a real one, and keeps the run-on as stress
Iris, on the two findings from the incremental-text investigation:
"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."

The streamed tail now takes a blank line every 4-12 deltas, so it is 53
markdown blocks with a longest of 502 characters instead of one block of
14,888 -- against a measured p50 of 147 and a largest-ever 1,580 over
7,706 blocks of real assistant messages. Layer 1's streaming frame went
from p50 3.86ms / p90 8.65ms / worst 10.95ms to p50 2.20 / p90 5.90 /
worst 8.78.

The run-on message is kept as the first two backlog events, 14,824
characters in one block, just under text_cap's 16 KiB so it draws in
full. The *streaming* pathology stays in frame_profile.rs rather than the
fixture: it needs a growing block, and iterating on it there costs a
second instead of 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 tidiness: the first attempt shifted the backlog and broke
`a_long_press_and_drag_selects_text`, which replays a real recording at
(300, 1000) and needs the content it was recorded against to still be
there. BACKLOG_COUNT is 3202 now, in generate.py, fixture.rs and
BenchFixture.kt, which split the file by line index.

And the answer to Iris's question, which the code already had: the newest
message does *not* cap. `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 growing past the cap is never caught either since it grows through
apply_delta. So a streamed block's shaping cost has no ceiling -- ~29ms
per delta at 50k characters, ~58ms at 100k.

Recorded but not chased: the emulator's `stream: build p50` did not move
(10.4 -> 10.5ms) while layer 1's frame nearly halved, so most of a
streaming frame on a GPU path is the whole-arena primitive re-upload
layer 1 never performs -- 11,568 primitives rewritten per delta, with the
fling phase as the control at 0.4ms for the same primitives moved
through move_offsets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:32:59 -04:00
irisandClaude Opus 5 43a3a345e4 Incremental text: parley cannot, the app already does it, and the 9.5ms is the fixture
Iris asked to look into incremental text rendering, hoping parley
supported it. It does not, by design: a `Layout` re-linebreaks and
re-aligns freely but "if the text content or the styles applied to that
content change then a new `Layout` must be created", its LRU cache holds
harfrust's per-font shaper data rather than shaped runs, and its own
`PlainEditor` rebuilds the whole layout from the whole buffer on every
keystroke.

The app already does what incremental layout would buy: `RowBlocks::
apply_delta` keeps one `TextEdit` per markdown block and re-shapes only
the one a delta landed in. Re-splitting the markdown to find it is 18µs
at 18,000 characters; comparing the blocks is 470ns.

What is left is one `TextBuffer::shape` of that block, linear in its
length at ~0.23ms per 1,000 characters here -- and the bench fixture's
streamed message is 14,888 characters in a *single* block, a run-on
paragraph with no blank line in it, so every delta reshapes all of it.
That is 3.5ms of the measured 3.86ms frame.

Real replies are not that: across 7,706 top-level blocks from 3,675 real
assistant messages on this machine (lengths only, no content copied
anywhere), p50 147 characters, p90 449, p99 836, largest 1,580, nothing
above 4,000; code fences p50 126, largest 589. At those sizes a reshape
is 48µs to 372µs here, roughly 0.12-0.93ms on the phone -- inside a
120Hz budget with no incremental anything.

So the recommendation is not to build it, and to give the fixture's
streamed message the paragraph structure a real reply has instead. Three
runs added to `frame_profile.rs` so none of this is re-derived: what
reshaping a growing message costs (including at the sizes real replies
reach), where a delta's cost is, and what the fixture actually streams.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:15:57 -04:00
irisandClaude Opus 5 9bf714fa2e The frame report says what it measured: idle is not stutter, waiting is not late
Iris's phone came back "now THAT is smooth", and reading that run against
the bench's own timings found three things the report was getting wrong
-- two of them shipped yesterday in the fix for the last three.

`missed vsyncs` counted idleness. Every gap between frames was treated as
cadence, so the bench's own pauses read as stutter: 276 for sixteen 300ms
rests between flings, 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 one.

`late` counted the swapchain 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 -- 0.4ms of work and
5.7ms of waiting is not a late frame. It is judged on `FrameParts::work`.

And 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. The first attempt at measuring it instead took the
fastest tenth of the gaps and reported 88Hz for this repo's 60Hz
emulator, whose app manages 54 -- 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. A sustained rate is
a floor and cannot do that. Both are printed when they disagree.

Also corrected in the docs: "103fps on a 120Hz screen" divided the fling
phase by its whole duration, rests included. Both runs sustained ~120.3fps
through the motion, so the callback ordering was never costing frames --
what changed is the clock, which moves no frame count at all, which is
exactly why nothing in a report could show it.

`fling_profile.rs` is `frame_profile.rs` and gained a stream run, which
says where the frame time now is: folding an arriving event is 0.35ms and
applying the diff 0.41ms, while the frame is 3.86ms here and 9.5ms on the
phone. 401 events move the item count 652 -> 654, so nearly every one is
a delta into the same row -- the cost is re-shaping one growing message,
not `fold_event`'s per-event clone, which was the hypothesis and is what
measuring it ruled out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:08:30 -04:00
irisandClaude Opus 5 42d54eec95 Fling: the vsync clock, the frame ask, and a report that can say what it measured
Iris, from her phone: "some stuttering when flinging in particular.
Harder to notice with my finger directly moving the scroll." Her fling
phase was 103fps on a 120Hz screen at p50 6.3ms.

Two of the four things found are corrections to the instrument, not the
renderer. The swapchain acquire -- `get_current_texture`, which *blocks*
until the compositor frees an image -- was inside the span the report
called iris's CPU work, so a fling comfortably ahead of the display read
as milliseconds of being slow. A frame is now three measured parts
(`FrameParts`: build, acquire, submit), per phase as well as per run. And
nothing could say a frame was never *produced*: `late` counts frames that
cost too much, which a reader does not see, while a frame that never
happens leaves the last one up for two refreshes, which is the stutter.
`PhaseStats::missed` counts vsyncs nothing was drawn for. It closes on
the emulator: 1548 frames + 452 missed over 33.0s at 60Hz is 1980
vsyncs.

The other two are the frame loop. `Choreographer.postFrameCallback`
schedules for the next vsync after the call, and iris asked at the *end*
of the callback -- so any frame whose work ran past the boundary
registered too late and got the vsync after, one frame over budget
silently costing a second. It is asked for immediately after
`tick_animations` now, on both backends. And the fling was advanced on
`Instant::now()` rather than the vsync `do_frame` carries: frames are
presented on an even cadence whatever clock computes them, so sampling
the spline at "whenever the callback ran" moves the content unevenly with
no frame late enough to appear in any report -- and a drag never had it,
which is the asymmetry Iris described. `PointerClock` is `DeviceClock`
and the view keeps one, anchored by whichever of a touch or a frame comes
first, so a fling is advanced on the clock its velocity was measured on.

`opt-level` for the Android release build goes from "s" to 3. The table
in RUST.md picked "s" on bytes alone; 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, for 1.8 MB of arm64 APK.

`app-rust/tests/fling_profile.rs` is the rig that established what a
fling frame actually costs and is kept for next time (Iris: "please keep
the profiling rig around for future use"): only one frame in six lays
anything out, and the multi-millisecond spikes are all first-pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:49:28 -04:00
irisandClaude Opus 5 4ccfda6b8e Delete the decisions and design logs; scripts, rigs and xtask off the root
Iris: "remove both decisions and iris.md. I've decided to instead make
decisions when planning with agents rather than after they do things, and
they're both too long for me to wanna read, + don't cover all the
decisions I'll wanna make about the code anyways. I'll just naturally run
into things for now. Todo is important though."

So docs/DECISIONS.md (850 lines) and docs/IRIS.md (1,986) are gone, and
AGENTS.md now says not to start another: raise a choice while planning it
with her, otherwise decide it and put the reasoning at the code it
governs. The TODO lists stay. docs/SUBAGENTS_DECISIONS.md went with them
-- same artefact, same reasoning, and she did not name it, so its six
decisions were folded into docs/SUBAGENTS.md rather than deleted.

Deleting the logs left ~30 citations dangling in code comments and docs.
Each states its reason inline and cited the file only for provenance, so
they now read "decided 2026-09-07" or name the module doc that carries
the reasoning.

The root had six things that were not a program or a document. Moved,
per "I only meant top level sh files":

  run-tests.sh, test-wg-tunnel.sh, wg-setup-host.sh  -> scripts/
  rigs/                                              -> scripts/rigs/
  xtask/                                             -> scripts/xtask/

A project's own scripts stayed with the project: app/*.sh, app-rust/*.sh,
iris/*.sh and server/enroll-link.sh did not move.

`target/` at the root is deleted and cannot come back: there was never a
workspace there, and the 29 MB was only xtask's scratch space, now in
scripts/xtask/target/. `cargo xtask apk` still runs from the repo root
and now publishes to scripts/build/outputs/apk/<mode>/ -- one directory
deep, because that is what Dev Updater's `*/build/outputs/apk/*/*.apk`
discovery pattern needs, and scripts/xtask/build would have been two.

Verified: ./scripts/run-tests.sh and `cd iris && cargo test` green, clippy
and fmt clean everywhere, `cargo xtask apk debug --abi x86_64` builds and
signs an APK carrying lib/x86_64/libai_app.so at the new publish path, and
the repo root is now eleven entries with no build output among them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:16:24 -04:00
irisandClaude Opus 5 09778346a0 Prune the docs of work already done: 18,252 -> 7,567 lines
Iris: "the documentation is also pretty crazy too. Can you go through it
and remove everything that's already done and decided? There's entire md
files iirc for projects already complete. And many with checkboxes already
ticked off that just fill up context."

  docs/RUST.md        8503 -> 905    the framework bake-off (options,
                                     recommendation, twelve closed
                                     experiment boxes) and two superseded
                                     "where things stand" sections, out;
                                     what the experiments settled kept as
                                     one line each
  docs/IRIS_TODO.md   1383 -> 229    fifty closed items and six
                                     phone-report sections whose defects
                                     are all fixed
  docs/LAYOUT.md      1116 -> 829    the pre-implementation framing: the
                                     old trait, the checklist, the
                                     migration list, the pass conditions
  docs/TEXTURES.md     496 -> 240    the prior-art survey, the proposal
                                     and its review, all implemented
  docs/REVIEW-*.md     673 -> 0      two completed review passes; the two
                                     findings left open on purpose (mask
                                     hit-testing, the phone's font set)
                                     moved into RUST.md

What survives a prune is what cannot be cheaply re-derived: measurements
(the APK-size table, the phone bench reports), dead ends, invariants and
their reasons, and the design of what exists now rather than the route to
it. AGENTS.md now says that, so the next session prunes as it goes rather
than appending; docs/IRIS_TODO.md's header says items are deleted when
they land rather than ticked.

Deleting the two review files left eighteen citations dangling in code
comments that state their reason inline and cited the file for provenance
only — those now read "(review, 2026-09-06)" and carry no dead pointer.
The emulator's measured GPU capabilities moved to the this-machine-android
skill, where machine facts belong. IRIS.md and DECISIONS.md are dated
records and were not rewritten; each gained one note that paths in older
entries predate the 2026-09-08 crate merge, pointing at the mapping.

Not touched, deliberately: docs/DECISIONS.md's entries (that file *is* the
queue of things for Iris to review, so deleting decided items would remove
what it exists for) and iris/readme.md and iris/TODO, which are hers.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in every workspace, and every remaining docs/*.md cross-reference
resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:50:53 -04:00
irisandClaude Opus 5 6d5a231f5c iris is the framework alone; the app is one crate in app-rust/
Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."

Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:

  client-core                -> app-rust/src/client
  iris/transcript-ui         -> app-rust/src/ui
  iris/transcript-fixture    -> app-rust/src/ui/fixture.rs + tests/ + touch/
  iris/desktop-app           -> app-rust/src/desktop + src/bin_desktop.rs
  iris/android-app           -> app-rust/src/android + android-project/
  android-shell              -> app-rust/src/shell

iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.

Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.

DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:36:38 -04:00
iris e9a6562dc6 iris: masking is opt in, and a LazySpan only culls
Iris, correcting the previous commit: "Why does the mask matter at all.
If you want a mask then you add .masked(). It should just prevent rows
that aren't in its region at all from drawing ... Just like the opt in
scrollable, masking should be opt in."

So `LazySpan` sets no mask. It culls -- a row entirely outside the box it
was offered is never drawn, which `intersects_viewport` already did -- and
draws a straddling row in full, because virtualisation decides which rows
and never how much of one. Cutting off that overhang is `.masked()`, added
by whoever wants it.

The transcript wants it (it is a list under a header bar) and opts back
in; the benchmark does not and needs no ceremony. `top_edge.rs` goes back
to reading the mask the list *inherited*, which is now also the test that
the transcript is still asking for one.

The previous commit had the span mask itself, which fixes the panic and is
still the widget deciding what is not its to decide.
2026-09-08 23:08:13 -04:00
iris afbc2ad132 Cap what the transcript draws, and let a LazySpan clip itself
Four things Iris asked for on 2026-09-08.

**A LazySpan no longer cares about masks.** It asserted that something
around it had called `.masked()` and refused to draw otherwise, which is
why a plain full-screen list -- the benchmark, any simple app -- panicked.
It cared only because it draws a row straddling an edge in full and relied
on somebody else to cut off the overhang; it clips itself to the box it
was offered now. Strictly stronger than the assert, which a mask *larger*
than the list's box satisfied while letting the overhang through anyway --
the fault it was written for. The transcript's `.masked()` wrapper goes
with it, and `Painter::is_masked` with that.

**Everything on the transcript screen is capped.** One rule in one place,
`client_core::text_cap`, mirrored as `TextCap.kt` with the same numbers so
a bench comparing the apps compares renderers rather than policies:

    a tool call's input    80 lines or 4 KiB   -> "Show all N lines"
    a tool call's output   80 lines or 4 KiB   -> (already was, in iris)
    a message             200 lines or 16 KiB  -> "Show all N lines"

The input is what the edit-card report needed: an Edit's old_string and
new_string arrive whole and are routinely the biggest text on screen.
Messages are capped in both apps, user and agent alike.

Three rules that took a screenshot to get right. A message is cut on a
block boundary, never mid-block -- cut to its own opening line a fence
renders as an empty panel, which reads as a fault rather than as a cap --
except a message that is one enormous block, which is truncated, since
dropping it would leave the row blank. A reply still streaming is never
capped. And the input's two blocks share one "Show all", while input and
output have their own.

**Compose stops wrapping raw text**, per Iris's call: a tool's leftover
input fields and its output pan sideways like the command already did.

`on_tap` and hold-the-edge move to `transcript-ui/src/tap.rs`, since a
message's "Show all" needs exactly what a tool card's tap already had.
2026-09-08 22:59:18 -04:00
iris 1318e149f5 iris: redrawing one widget cost O(its own primitives squared)
Iris's report was that expanding a tool card holding a long,
horizontally-scrolling edit lags on her phone. The cause is not text
layout: shaping and rasterising a 51,200-glyph block is 20ms, and the
frame that drew it took 1.37 seconds.

A widget redrawn in place frees every primitive it owned and writes
fresh ones. Freeing compacts each layer's draw order with swap_remove,
so ~N primitives are renumbered, and finding the handle to renumber was
a linear scan of everything that widget drew -- O(N^2) in the widget's
own primitive count. A paragraph never notices; one text widget holding
a whole old_string and new_string is every glyph in the card.

The arena now records, per slot, where that slot's handle sits in its
owner's ActiveData::primitives, written at the one place a handle is
taken (Painter::own), and apply_free indexes straight to it.

    50,000 glyphs, redrawn:  before 636ms   after 2.4ms
    per glyph:               before 12.7us  after 0.043us, flat in N

benches/message_list.rs gains scenario (g) for it, reporting per-glyph
because flat is the pass condition and a total hides it. That file had
also stopped running entirely: scenarios (a) and (e) built a LazySpan
with no mask around it, which the span now asserts against, so the
benchmark panicked on its second line. Fixed here too.

Also, on Iris's instruction: the copied report no longer inlines a tail
of the app log. Dev Updater's Runtime tab reads the same ring through
devlog's provider, so it was the same lines twice; the diagnostics pane
still names the provider's authority to read them from.
2026-09-08 22:22:06 -04:00
irisandClaude Opus 5 4fdabc39d0 iris: one ScrollController, a Scrollable trait, and Pin
Iris's three points on docs/SCROLL.md, in the shape she proposed: a
controller both scrolling widgets *contain*, rather than a protocol
between them. "I don't like adding methods to widget, it seems like we
can structure things better instead."

`Scroll` becomes `ScrollArea`, because it only scrolls a predefined area.
`ScrollController` holds everything that is not a particular widget's
layout -- the position, the pending delta, the travel left each way, the
pin, the DragGesture and the Flinger -- and `Scrollable` is the trait over
it, one required pair of methods with the rest defaulted.

`Widget` loses `scrolls_itself`, `apply_scroll` and `scroll_offset`. They
existed only so a `Scroll` could drive a `LazySpan` it had no business
wrapping; the span owns its own controller now, so the wrapper, the
measure/apply/place dance between two widgets and `amt`'s two meanings all
go with them. The transcript's tree loses a node: `list` is the layout and
the position.

`.scrollable(axis, pin)` replaces `scrollable`/`scrollable_on`/
`scrollable_to_end` -- one mechanism whose arguments had been hidden in
three names. `LazySpan` has an inherent `scrollable()` that shadows it,
since Rust resolves inherent methods before trait ones: the same word at
the call site, and the wrapping version cannot reach the one widget that
must not be wrapped.

`Pin` says which end either way round: `Start`/`End` are content-relative
and `Neg`/`Pos` axis-absolute, so a caller can say "the bottom" and mean
it whichever way the content runs. They differ only for a reversed span,
which is the whole reason both exist.

One behaviour changes: a delta is applied by the next draw rather than
where it arrives, since the layout is the only thing that knows where the
content ends. Nothing on screen differs -- input is followed by a frame --
but `amt` no longer moves between draws, which several tests were reading.
This also closes SCROLL.md's open question about the pin living in two
places.

Verified: cargo test --workspace (all green, including the layer-1
transcript-fixture fling/selection/top-edge tests), clippy --all-targets
clean, fmt clean, `cargo ndk` check of android-app, and
`run-headless.sh phone --phone --replay flick-120hz.touch`, whose
before/after screenshots show the recorded flick carrying the transcript
back from turn 270 to turn 258 on the Vulkan adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:51:53 -04:00
irisandClaude Opus 5 bf8658c404 iris: a scroll delta's sign is a screen direction, not a logical one
Positive scrolls the reader up or left and negative down or right,
whichever way the widget receiving it lays its content out (Iris,
2026-09-08: "that way it always works as the user would expect").

`LazySpan` took the delta straight into the direction-relative space its
walk works in, so a `Dir::UP` span -- whose later content is *above* --
panned the opposite way from every other scrollable in iris for the same
number. `flip_delta` is the conversion, the counterpart of the `flip_pos`
that positions already went through, and the two places that meet the
outside world (`apply_scroll` and `moved`) are the only ones that use it.

Nothing built a `Dir::UP` span yet, so this was latent; the existing sign
test could not have found it either, since it asserts in the walk's own
space where both halves agree with each other while disagreeing with the
screen. The new test compares the two `dir`s against where rows were
actually drawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:32:30 -04:00
irisandClaude Opus 5 00e0a63887 docs: SCROLL.md, the standing reference for how iris scrolls
For the next session, since this one is about to be cleared. Current
design only -- `Scroll` owns the position, the gesture and the fling; a
child is either moved or answers `Widget::scrolls_itself` and is handed
deltas; one sign convention, the finger's. It carries the things that are
expensive to rediscover and easy to undo by accident: why the two `&self`
capability methods must not be `&mut` (`get_dyn_mut` marks dirty), why
`scroll_offset` exists beside `apply_scroll`'s remainder, why the
measuring draw is free, why nothing is marked by hand, why the height
cache stays in the container, why the transcript builds its `Scroll` by
hand instead of through `.scrollable_to_end()`, and the measured numbers
behind "a `LazySpan` is not a `Span`".

Also names the one thing still open -- the pin -- with the two ways to
close it and an instruction to ask Iris rather than guess.

`scroll.rs` and `lazy_span.rs` now point at it from their module docs
rather than restating it, AGENTS.md lists it beside the other design
documents, and IRIS_TODO.md's in-progress entry defers to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:11:34 -04:00
irisandClaude Opus 5 b7474f61b0 iris: scrolling belongs to Scroll, and a LazySpan only lays out
Steps 2 and 3 of the plan in docs/IRIS_TODO.md, together because
deleting the fling before `Scroll` could drive it would leave the app
unable to scroll at all. IRIS.md has the account and the measurements.

`LazySpan` loses its `Flinger`, its `density`, its
`Arc<dyn RequestRedraw>` -- which had no business existing in a
single-threaded frame loop -- its `tick`, and the whole
`fling`/`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity`
surface. `Scroll` was the only other `Flinger` user, so there is now one
implementation of the physics rather than two, and a transcript is
`list.scrollable_to_end()` like anything else.

Three new `Widget` methods carry the handoff:

    fn scrolls_itself(&self) -> bool { false }
    fn apply_scroll(&mut self, delta: &mut f32) {}
    fn scroll_offset(&self) -> f32 { 0.0 }

`Scroll` asks the first, and a child that says yes is handed deltas
instead of being slid about as a lump -- which a lazy layout cannot be,
since which rows exist at all is a function of where it is scrolled to,
and it has no content length to be clamped against. `scrolls_itself` is
`&self` deliberately: `Widgets::get_dyn_mut` marks a widget dirty, so
asking through `apply_scroll` would dirty every ordinary child on every
tick and cost exactly the O(1) move the scheme exists for.

`Scroll::draw` is measure, apply, place -- the idiom it already used for
its own content length. The measuring draw is free in the common case
(unchanged region, nothing dirty, `draw_inner` returns immediately and
the child's stored walls are still correct) and really walks exactly
when the content changed. Nothing is marked by hand: reaching the child
to hand it the delta is what dirties it, which is why `draw_again` could
stay deleted.

`scroll_offset` was not in the plan and is needed. A lazy span usually
cannot say where its content ends until it has walked there, so it takes
a delta in full whenever the wall is not already in view and the walk
gives part of it back; the remainder is exact only when the wall was
already visible, and `Scroll` adding remainders up would over-count by
every overshoot and never correct. It reads the child's accumulated
movement after the placing draw instead, so `amt` equals what is on
screen. `amt_counts_only_what_the_child_could_take` is the test.

One convention for a scroll delta, the finger's. `Scroll::scroll(+)`
moved toward the start while `LazySpan::scroll(+)` moved toward the end,
with the latter's doc claiming to mirror the former -- so every call site
had to know which it was talking to. `LazySpan::scroll` is private now
and the single negation is inside its `apply_scroll`; call sites that
passed `-dy`/`-v` pass them through, and `phone_screen.rs`'s recorded
velocity flips sign with its magnitude unchanged.
`a_negative_delta_moves_toward_the_end` pins the sign across the whole
handoff, since nothing else can catch a list scrolling backwards.

The transcript builds its `Scroll` by hand rather than through
`.scrollable_to_end()`: that helper registers a finger drag, and
`Selection` is already the arbiter for those frames -- two `DragGesture`s
seeing one gesture is what its own doc rules out. Caught by
`a_long_press_and_drag_selects_text`, which failed when both were live.

Deferred, in DECISIONS.md and IRIS_TODO.md: the *pin* is still each
widget's own. Applying one happens when a row is appended, between
frames with no painter in hand, so moving it to `Scroll` needs a fourth
`Widget` method or a parameter on `apply_scroll`; nothing external edits
a pin today.

Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace green (21 suites), the arm64 release APK builds,
and the phone-shaped headless window replaying flick-120hz.touch scrolls
back through the transcript in the direction it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:39:33 -04:00
irisandClaude Opus 5 8e5928cc6a iris: List becomes LazySpan, and takes a Dir
First of three steps agreed with Iris for getting scrolling out of the
list and into `Scroll`, so that `.scrollable()` is the one way anything
in iris scrolls. docs/IRIS_TODO.md's "In progress" block carries the
whole plan and the decisions behind it; this step is the rename and the
direction.

`List` -> `LazySpan`, and it moves in beside `Span` under
`widget/position/`. It is what `Span` is -- a sequence of children along
an axis -- laid out lazily from an anchor instead of eagerly from the
start, and the name says the one thing that matters about it. It also
stops colliding with `BlockKind::List` in the markdown code.
`ListRow` -> `LazyItem`; `RowKey` keeps its name, since rows are the
vocabulary in transcript-ui.

`Axis` -> `Dir`, with the sign meaning what it means in `Span`: which
end of the box item 0 sits at. **That is a different question from which
end the view is pinned to**, and conflating them would stand a
transcript on its head -- its oldest message is item 0 and sits at the
top (`Dir::DOWN`) while the view clings to the bottom. So the pin is its
own constructor argument, `LazySpan::new(dir, at_end)`, spelled the same
way as `Scroll::new`'s.

Making `Dir::UP` real rather than nominal is most of the diff. The walk
now works entirely in direction-relative pixels from the leading edge --
`Edge::Top`/`Bottom` are `Leading`/`Trailing`, `Placement` likewise, and
`RowExtent`'s fields and every local are `lead`/`trail` -- with two
places converting: `abs_region`, which flips the box for `Sign::Neg`,
and `flip_pos`, which converts the screen-space positions the public
helpers speak in (`note_tap`, `key_at`, `extent`, all fed by pointer
events) into the walk's space. Without the second, a reversed span would
hit-test at the mirror of where it drew.

`a_dir_up_span_grows_upward_from_item_zero` asserts on where each row was
**actually drawn** (`UiRenderState::active`), not on `extents`: the first
version of it read `extent()` and passed with `abs_region`'s flip deleted
-- checking the bookkeeping against itself while every row painted at the
mirror of where it belonged. It now fails with the flip removed (row 2 at
80..100 instead of 0..20), which is the check that matters.

Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace green (21 suites), including the phone-shaped
fixture tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:17:57 -04:00
irisandClaude Opus 5 76fcbdccb9 iris: a List gives back its overscroll in the frame that found it
The last place in iris that corrected itself on a later frame, and the
item docs/IRIS_TODO.md carried from the Scroll change. Iris's rule:
"nothing in the framework should ever self heal because it should not be
drawn incorrectly in the first place. If you need 2 draws to get
something into the correct position then that should happen within the
same frame."

`clamp_to_content` measured the gap past the end of the content from the
edges the walk had just placed, wrote it to the anchor and asked for
another frame -- so one frame was drawn with the list past its own end,
and a fling that had already stopped was not going to ask for the frame
that fixed it. Now the walk outward from the anchor is `List::lay_out`,
`overscroll_gap` is a pure measurement of the same gap (no painter, no
redraw handle), and `draw` moves the anchor and walks a second time
inside the same frame.

One further pass always settles it: the gap comes from the edges the
first walk placed, so moving the anchor by it puts that edge exactly on
the viewport's, and the opposite end can only open a new gap when the
content is shorter than the viewport, which `overscroll_gap` declines to
touch. The second walk is paid only on an overscrolled frame and re-offers
every row the same cached-height box at a new offset, which `draw_inner`
dispatches as an O(1) move.

`Painter::draw_again` had no other caller and is removed with it, so the
framework no longer offers a way to ask for a corrective frame at all.

Simplification in the same change: a placement is one pinned edge plus a
height, so `Placement::edges(height)` gives the box and `place`'s
top-known and bottom-known cases stop being two copies of the same
arithmetic -- four match arms down to two.

Four tests draw no settling frame on purpose and fail without the change:
`fling_toward_the_start_stops_at_the_first_row` and the new
`scrolling_past_the_start_is_given_back_in_the_same_frame` (list.rs), and
`scrolling_past_the_first_row_settles_on_it` /
`scrolling_past_the_last_row_settles_on_it` (layer 1, top_edge.rs).

Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace and ./run-tests.sh green, the phone-shaped
headless window replaying flick-120hz.touch draws the transcript
correctly, and the arm64 release APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:26:51 -04:00
irisandClaude Opus 5 a00376994e iris: a Scroll measures and places its content in the same frame
Follows Iris on the previous commit: "nothing in the framework should
ever self heal because it should not be drawn incorrectly in the first
place. If you need 2 draws to get something into the correct position
then that should happen within the same frame. Layout should never be
frame dependent, it should be a pure function of the state."

So `Scroll::draw` no longer places its child against last frame's
content length and asks for a corrective frame. It draws the child once
at that length purely to measure it, then places it at the length just
measured, with the end-pin and the clamp applied only to the second
placement -- the measure-then-place idiom `Span::draw` and `List::place`
already use. Last frame's length survives as a hint that keeps the
common case cheap: when the content's length did not change the two
regions are identical, so the first call is `draw_inner`'s O(1) `mov`
and the second returns at its first line. Nothing drawn depends on the
hint.

Reverts the frame-loop change from the previous commit (a frame that
left anything dirty asked for another), which existed only to deliver
that corrective frame and would have made any widget marking itself
dirty spin at full rate.

Knock-on: an end-anchored Scroll now sits at its end on its first drawn
frame rather than its second, since the end-pin no longer waits for a
length. Two layout tests that scroll down from what they assumed was the
top now build their area with `at_end: false`, which is what they meant.

`List::clamp_to_content` is the only next-frame correction left. Its
comment cited Scroll's lag as precedent, which no longer exists; it now
says it is a deviation from the rule, and docs/IRIS_TODO.md carries it.

Verified: the layer-1 test draws no settling frame and still passes; on
the emulator the caret's bottom is 1509 against a bar edge of 1535, 26px
inside a 31px padding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:12:26 -04:00
irisandClaude Opus 5 ba57086361 iris: a scroll area whose content grew asks to be drawn again
Iris's phone: typing newlines into the composer with the keyboard up
dropped the caret flush against the bar's bottom edge, eating the 12dp
padding, and closing the keyboard fixed it.

`Scroll::draw` offers its child last frame's content length on purpose,
so an ordinary scroll tick is an O(1) move rather than a redraw. The
comment claimed the lag self-corrects on the next frame; nothing asked
for that frame. A keystroke dirties the field, that frame draws it in a
box one line short of its text, and the tree is clean afterwards -- so
the stale placement is the last one drawn. The composer's text is
centred in its box, so one line short hung half a line past each end and
put the caret's line box a whole padding low. Closing the keyboard
rewrote the bar's inset, dirtied it, and forced the missing redraw.

`Scroll::draw` now calls `Painter::draw_again` when what it measured
differs from what it offered, and a frame that leaves anything dirty asks
for another frame on both backends -- `draw_again` sets its mark during
the update, after the input path's own check has run, so nothing asked
before this (which applied to `List::clamp_to_content` too).

Verified at layer 1 (the new test fails on the old code with the caret
exactly on the bar's edge) and on the emulator: the caret's bottom moved
from 1535 -- the bar's own bottom edge -- to 1509, 26px inside a 31px
padding, the remainder being parley's line box overhanging its line
height. `phone.rs` grew `--typed TEXT`, which enters text over frames
rather than preloading it; only that reproduces this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:59:06 -04:00
irisandClaude Opus 5 1a9655414e docs: Iris's idea for retiring masked_by -- a Stack that names its mask
She asked whether `masked_by` earns its place, since
`.background(x).masked()` looks like the same thing. Measured: for a
square-cornered surface it is (identical to the pixel on the composer at
the phone's size and density), and what the pair cannot express is a
clip that is not a box, which is why the method stands for now.

Her suggestion, in IRIS_TODO.md's "Reconsider": let `Stack` name where
its mask comes from the way `StackSize::Child(n)` already names where
its size comes from, at which point `masked_by` and `Masked::shape` both
go away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:38:05 -04:00
irisandClaude Opus 5 5e34dba2fd iris: a press only reaches the widget the pointer is on
Iris's 2026-09-08 report, both halves, and her own diagnosis of the
second: "tapping outside of something that a fling is currently active
for should have no code in common with the fling that could influence
it."

`run_sensors` runs a widget one frame after the pointer leaves it
(`ActivationState::End`, which is not `Off`) so `HoverEnd` can fire, and
`should_run` derived the press and wheel senses from raw button state
without consulting `hover`. That farewell frame carried a `PressStart` to
a widget the finger was nowhere near -- and a press on already-coasting
content is a catch, which commits to a pan with no `DRAG_SLOP`, so the
widget captured the pointer and swallowed the whole gesture. Its hover
was stale because a gesture that ends while captured returns from the
capture branch, which never reaches the loop that updates it.

Measured before the fix on the real screen: a fence flicked sideways,
then a finger down on a row 500px above it dragged 160px down the screen
-- the list moved by zero, the fence moved by zero, and the fence held
the pointer throughout. After: the list follows the finger and the
fence's fling carries on coasting, which is what she asked for and falls
out of the fix rather than being arranged.

`should_run` now requires `hover.is_on()` for every non-hover sense.
`Drop`/`Cancel` are unaffected -- they are delivered deliberately to a
widget that is not under the pointer, with an explicit `On`.

Also: the composer is clipped to its own bar rather than inside its
padding (`.masked_by(rect(BAR_FILL))` in place of a `.masked()` +
`.background()` pair) -- "the box should be clipped rather than the inset
text". A long message was being sliced mid-glyph 12dp in from the bar's
edge, leaving a band of bare surface above the cut.

New: `Scroll::is_scrolling`, the name `List` already uses; the phone
rig's `--message TEXT` and `--ime PX`, since the composer's overflowing
and keyboard-open states cannot otherwise be looked at headlessly.

Tests fail on the old code, one per layer:
`a_press_does_not_reach_a_widget_the_pointer_has_just_left` (sensors, no
screen) and `a_drag_away_from_a_coasting_fence_scrolls_the_list_and_
leaves_it_coasting` (the report itself, layer 1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:23:32 -04:00
irisandClaude Opus 5 cc8148cbec deps: every crate to its latest version, wgpu 28 -> 30
`cargo upgrade --incompatible` in each of the nine workspaces here, then
`cargo update`. Most of it is version numbers only -- log, winit,
bytemuck, image, tokio, libc, android_logger, proc-macro2/quote, and syn
2 -> 3 with no source change. The wg-app-link submodule's twelve
dependencies were already at their latest majors, so that shared
repository needs no commit.

wgpu 28 -> 30 (and pollster 0.4 -> 1.0) is the part with API in it:
bind-group and vertex-buffer slots are optional now, `Instance::new`
takes an owned `InstanceDescriptor` carrying the platform's display
handle (the desktop passes winit's, since wgpu wants it for a GLES
surface presented on Wayland -- which is what this machine's fallback
produces; Android passes none), `RequestAdapterOptions` and
`SurfaceConfiguration` each gained a field kept at its historical value,
`get_current_texture` answers with an enum instead of a Result, and
`present` moved onto the queue.

The one that would not have failed at compile time: naga now requires
`@interpolate(flat)` on integer varyings, so `shader.wgsl`'s three u32
outputs were rejected at `create_shader_module` -- an abort on the device
rather than a build error. Flat is the only interpolation an integer can
have, so this states what the hardware already did.

Checked: build, clippy, fmt and tests in all nine workspaces (iris 196,
server 160); layer 2 screenshots on Vulkan and on force-gles, identical;
the arm64 release APK builds and the x86_64 bench ran a full
fling/stream/type/keyboard cycle on the emulator's GLES adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:47:59 -04:00
irisandClaude Opus 5 a2e5e5881c docs: the two warnings the bench Android build still prints, and why they stand
Both are pre-existing and both are decisions rather than cleanups.
`show_diagnostics_overlay` and the Java overlay behind it are an escape
hatch that draws a report even when iris itself has stopped drawing --
the one case the in-iris diagnostics pane cannot cover -- so deleting
them to clear the warning would remove a fallback, and Iris has no
logcat on her phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:28:21 -04:00
irisandClaude Opus 5 c121bc0725 iris-android-app: FIELDS_PER_LINE is gated with the reader that uses it
`cargo ndk check` on the default features warned that it was never used:
its only reader is `line_fields`, which is `#[cfg(feature =
"transcript-screen")]` because the tabs demo links no `client-core` and
so has no ring to lay out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:27:31 -04:00
irisandClaude Opus 5 fe7dc9c728 docs: Iris's second 2026-09-08 phone report, and the workaround list closed
RUST.md gets the report verbatim with what each of the four defects
actually was, the tests that pin them, and two traps worth not
re-finding (a fixed-coordinate tap that "failed" by 544px because it had
toggled a tool group, and a layer-1 repro that only reproduces inside a
`List`). IRIS.md and DECISIONS.md get the design half: one `Flinger`
whose seam puts the sign convention and the content's end with the
caller, a cancel as a first-class end to a gesture, and why a row is
drawn twice on the frame its height changes.

LAYOUT.md gains the two rules those turned on, since both govern the
layout rather than this pass: padding works in any container and is an
inset or an outset depending on how tight the parent's region is (Iris's
own words), and a widget offered a box it does not fit is drawn again at
its true box in the same frame rather than the next one.

IRIS_TODO.md's "worked around in tool.rs rather than fixed here" is gone
-- Iris, 2026-09-08: "There should never be workaround code." Two of the
four entries are ticked; the two that remain are missing capabilities
rather than defects being dodged, and each now carries a diagnosis of
what building it costs instead of a workaround: an overflow ellipsis
needs `TextBuffer` to have a displayed string distinct from its source
(parley has none of its own, and every byte-offset consumer -- spans,
`byte_at`, `Selection`, `apply_delta` -- moves if the buffer is
truncated), and selectable tool-card text needs a register/unregister
lifecycle across the three routes that rebuild a card, which is where a
stale `Selection` handle panics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:26:12 -04:00
irisandClaude Opus 5 02b277e7ad iris: every scroll area flings, on either axis, through one Flinger
Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll areas.
Flinging should be enabled by default in all scroll areas on android to
match composes behavior." Compose's `scrollable` attaches
`ScrollableDefaults.flingBehavior()` on every axis it is given and it is
not something a caller opts into, so neither is this.

`iris::sense::Flinger` is the fling `List` already had, taken out of it:
the `FlingCalculator` curve, the clock (started at the first tick, not
the release, so a caller on an explicit clock is not handed a fling that
has already expired), the incremental delta, Compose's two release
thresholds and the trace line. What it deliberately does *not* know is
which way a positive delta moves the content or whether there is content
left to move into -- a `List` scrolls its anchor one way and a `Scroll`
moves its `amt` the other, so the caller applies `tick`'s delta in its
own convention and calls `stop` at its own wall. `List` keeps
`fling`/`tick_fling`/`is_scrolling`/`cancel_fling` unchanged as a
surface, now three lines each over the shared type.

`Scroll` gains it, plus the two things a coasting widget needs and it
had no reason to have before: the display density (read from the painter
in `draw`, since the deceleration is physical -- a hardcoded 1.0 made a
one-second coast run for 45 on a list), and `PressState::scrolling`, so
a finger put down on a coasting fence stops it there from the first
sample rather than after `DRAG_SLOP`. `Scroll::drag` now answers whether
it started a fling, which is what `WidgetLike::scroll_area` needs to
call `UiData::animate` -- the same split `List::fling`'s doc describes,
and for the same reason: only the caller can reach the frame loop.

`Scroll::axis()` is public for a caller that found the widget rather
than built it.

Tests: `scroll.rs`'s three (a released pan coasts and decelerates on both
axes; both walls stop it; a press on coasting content catches it with no
slop), and `transcript-fixture/tests/fence_fling.rs`, which flicks a real
markdown fence in the real transcript screen and reads the fence's own
`Scroll` back out of what was drawn. Confirmed to fail with the release
arm removed ("the fence stopped dead at the release: 272 -> 272").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:22:03 -04:00
irisandClaude Opus 5 fc82d9d7e8 iris: a cancelled gesture is not a release, and a row height is not last frame's
Three of the four defects in Iris's 2026-09-08 report, each with a
layer-1 repro that fails without the change.

**A gesture the platform takes away is now a cancel, not a release**
(`CursorState::cancelled`, `SensorUi::run_sensors`). Android mapped
`ACTION_CANCEL` onto the same arm as `ACTION_UP`, so the system's own
swipe up from the bottom edge to leave the app reached iris as a flick
released at speed and the transcript flung while the app was in the
background -- "leaving and reopening the app also randomly moved the
vertical scroll". A cancelled sample now delivers `CursorSense::Cancel`
to the capture holder *and* every widget still tracking the press,
clears both, and derives nothing else: no tap, no selection, no fling.
The harness's `TouchAction::Cancel` says the same thing, so it is
testable from a `.touch` file.

**A `DragGesture` ignores a `Cancel` when it is the one holding the
capture.** A cancel goes to every pressed widget that did not capture,
and one gesture is routinely driven by several of those -- a transcript
row's text block feeds `Selection`'s shared gesture, which captures
under the *list's* id, so the block is a "loser" on the very frame its
own pan committed. Acting on that released the pan the frame it started
(`catch_a_fling.rs` fails without the guard). With it, a row's block can
register the whole `drag_senses()` set, `Cancel` included, which is what
the doc on that set has always said a widget driving a gesture must do.

**A row whose measurement disagrees with the box it was offered is drawn
again at its true box, this frame** (`List::place`, both placements).
A row is offered its *cached* height and a `.background(rect(..))` fills
whatever box it is handed, so on the frame a row changed height its text
laid out at the new height and its background painted at the old one --
"collapsing and opening an edit card draws the card background a frame
late, so it looks closed even when there's text". The bottom-anchored
half used a `reposition`, which writes an offset and never a size, so it
could not fix it either.

**The nested-`Span` workaround in `tool.rs` is gone**, restoring the 4dp
inset a tool group holds its cards off its edge by. "A `Span` of
`Pad`ded children inside another `Span` places those children a slot out
of step" is **not reproducible on 2026-09-08** -- verified both with
`IRIS_TOOLS_EXPANDED=1 run-headless.sh transcript --shot` and with a new
layer-1 test.

Tests: `transcript-fixture/tests/gesture_cancel.rs` (three, including a
real code fence pushed into the screen so the pan has something to
capture it), `list.rs`'s
`a_row_that_changes_height_draws_its_background_at_the_new_height_immediately`,
`layout_tests.rs`'s
`a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is`.
Each was confirmed to fail with the change backed out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:15:29 -04:00
irisandClaude Opus 5 9e301f30c6 iris: ship an icon font subset, and delete the drawn mark
Iris asked why `mark` existed at all -- "the font should be working if
it's working for compose and nerd fonts are bundled". It was not: the
Compose app draws its icons from its own committed Nerd Fonts subset,
while iris, which bundles no font since 2026-09-07, was setting the
disclosure mark with bare geometric codepoints (U+25B8/25BE/25B4) out of
whatever face the platform resolved -- an empty box on her phone, a dot
on this VM. The 2026-09-07 note that "iris had no equivalent icon font to
keep" is the gap: it had none because it had never had one.

So iris ships the same kind of subset. iris/core/build-icon-font.sh is
the Compose script with its own GLYPHS list, writing a 992-byte
nerd_icons.ttf with three Material Design glyphs from the Mono face;
iris::icon names the codepoints; Family::Icons is how text asks for them.
The variant names an intention rather than a font name -- only TextData
knows what the file registered as, and it resolves it during shaping --
and it is a named family, never a generic one, so nothing falls back into
it for text and an icon cannot fall back out of it onto a system face
that happens to have the codepoint.

every_icon_is_in_the_bundled_font maps each constant through the shipped
font's charmap, which is the guard the script's "the two lists have to
agree" comment asks for. FontDiagnostics gains icon_family, so a build
whose font failed to register says so instead of drawing tofu; the
emulator reports icons=Some("Symbols Nerd Font Mono").

widget/mark.rs is deleted. It drew one correct triangle, but every
further icon would have been another rasteriser, and an icon as text
takes the size, colour and baseline of the line it sits in for free.

Looked at rather than only compiled: closed and open marks in
run-headless.sh phone --phone either side of a tap, and the collapse
bar's up mark under IRIS_TOOLS_EXPANDED=1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:42:16 -04:00
irisandClaude Opus 5 341b7a5922 iris: a device change re-uploads its textures, and a mark is one texture per shape
The bench APK panicked on frame 1 on the emulator:

    iris panic at iris/core/src/render/texture.rs:461:22:
    texture slot 89 is not a live standalone image: None

widget::mark called Textures::add per widget, so a folded card per tool
call meant a standalone image, a bind group and a draw call each --
hundreds of copies of three pictures. Textures::reset, which the Android
surface-rebuild path calls for a genuinely new renderer, then threw the
slot numbering away with the pixels, leaving every one of those live
handles naming a slot nothing recognised. Its doc had said the only
standalone image in the workspace was tabs-ui's, "confirmed by grep" --
true when written, false the moment mark existed.

Textures::reupload replaces reset: queue every slot for upload again in
slot order, empty slots included, so the new device gets the same slot
numbering and a handle a widget has been holding still names its own
texture. The glyph atlas is no longer cleared on that path either, so an
app switch stops re-rasterising every glyph on screen.

Textures::shared(key, make) is one texture per description, keyed by a
SharedTextureKey the caller packs exactly rather than hashes. mark keys on
direction and colour: three mark textures for the screen, not one a card.

And the devlog can finally show a panic. After a crash, Dev Updater's
query starts the app process for the provider alone, so no activity ran,
so set_crash_dir never replayed the panic hook's file -- the Runtime tab
held one line, the provider announcing itself. DevLogProvider.nativeReady
takes the files directory and does the replay from onCreate; the hook also
saves the dying run's last 80 lines beside the panic, read through a new
non-blocking LogRing::try_tail_text so a panic holding the ring's lock
cannot deadlock the hook.

Verified on this checkout's emulator: opens clean, survives 33 full-screen
scrolls back through the fixture, image_bind_group_creates_prev=1; a real
panic replays into the next launch, and a hand-written last-panic.txt
replays in a process started by a provider query with no activity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:27:09 -04:00
irisandClaude Opus 5 c8785b6091 docs/IRIS.md: it is the log of how iris is being built, not an API changelog
Iris, 2026-09-08: 'any major additions or design things should be added
there, not just public API stuff. You may as well remove the public API
bit at this point.' Widened the header, pointed AGENTS.md at the new
scope, and added the design point behind the scroll bug -- a cached
measurement needs its own value for 'not measured yet'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:03:29 -04:00
irisandClaude Opus 5 9c560e3492 docs: tick the drawn chevron
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:01:42 -04:00
irisandClaude Opus 5 e5a90c6135 iris: mark() -- a drawn disclosure triangle, instead of a codepoint the phone lacks
The tool cards' open/closed marks were U+25B8/25BE/25B4 in whatever face
resolved. That worked while iris bundled its own fonts; since the move to
the platform collection on 2026-09-07 Iris's phone draws an empty box and
this machine draws a dot -- UI_RULES' 'don't rely on characters the
platform might not have'.

iris::widget::mark rasterises one oversampled, antialiased triangle into
the ordinary texture path and scales it into the box the caller asks for,
so it needs no new primitive and is correct at any density. Its two tests
check the shape points where it was asked to and leaves its corners
clear, which is the half nobody would look at on a device that renders it
wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:01:31 -04:00
irisandClaude Opus 5 af1b0c5ab2 docs/RUST.md: what the folded-card sanity check found
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:35 -04:00
irisandClaude Opus 5 cce4b28324 iris: a scroll area no longer opens at the end of content it has not measured
Scroll::content_len was 0.0 both for 'nothing here' and for 'not drawn
yet', so the first frame's clamp found a scroll range of zero, read
amt == len as 'sitting at the end' and set snap_end -- and the next
frame, now knowing the real length, jumped to it. On a phone that put a
code fence at the end of its longest line, mid-word, before anybody
touched it. It is an Option now, and the clamp does not answer a question
it cannot yet answer.

Which edge an area opens at is also a caller's decision rather than a
default: scrollable_on starts at the beginning (what is read),
scrollable_to_end pins to the end while content grows (what is typed --
the composer), both through one Scroll::new(inner, axis, at_end).

And tool.rs's raw_block pans sideways again: the 2026-09-06 'a
scrollable_on(Axis::X) around a non-editable Text draws nothing' defect
does not reproduce, most likely fixed by the shaped-mask work, so a long
command is readable rather than clipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:16 -04:00
irisandClaude Opus 5 94d8373289 iris-android-app: the bench observes the fling instead of driving it at 60Hz
The fling phase called List::tick_fling itself every 16ms, so on a 120Hz
phone every second frame redrew a position already drawn -- Iris saw the
benchmark scroll visibly less smoothly than her own finger, and it was
the rig rather than the renderer. A real fling is advanced once per frame
by UiData::tick_animations from the frame callback, so the phase now
starts one the way a gesture does (fling + animate) and polls
is_scrolling to know when it settled. ANIM_STEP_MS becomes POLL_MS,
which is what it always was here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:48:17 -04:00
irisandClaude Opus 5 8310431497 iris: pin the nested-scroll axis rule, and record the capture fix in RUST.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:46:39 -04:00
irisandClaude Opus 5 b863f9f3df iris: a capture cancels every other gesture, and the pointer leaves UiRenderState
Two defects Iris reported from her phone on 2026-09-08, one root cause
each, both in how a gesture ends.

A widget that takes pointer capture cuts every other widget off from the
press completely -- no PressEnd, no Drop -- so anything else tracking it
was left with an open gesture at a stale origin, and the *next* touch
anywhere was measured from that origin. That is the transcript jumping on
a tap after a code fence was panned sideways. CursorSense::Cancel is the
missing state: delivered once to each loser of a capture race, the way
Android sends ACTION_CANCEL and the web sends pointercancel.

And  registered click_or_drag|unclick, which never matches
a Drop, so a Scroll that had captured never saw its own gesture end and
stayed panning from where the finger left. That is the horizontal snap
back. CursorSense::drag_senses() states the rule once for every widget
driving a DragGesture instead of per call site.

The pointer's own state (who holds capture, who is tracking the press) no
longer lives in a Mutex on UiRenderState. It is Event::Global for the
cursor senses -- owned by the event manager that runs the dispatch,
reached by &mut, with a per-dispatch PointerRequests slot for handlers --
per Iris: never reach for locks first, and input-wide state belongs to
the general input handler. What had forced the lock was a Data: Send
bound on task_on that nothing needed; the spawned future never sees the
event's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:44:51 -04:00
irisandClaude Opus 5 cdeb7b0857 docs/RUST.md: the work is done inline, not handed to subagents (Iris, 2026-09-08)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:31:02 -04:00
irisandClaude Opus 5 476609e1d3 docs/RUST.md: Iris's 2026-09-08 phone report -- tap-jump, nested scroll, folded cards, and the bench's 60Hz gesture
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:27:46 -04:00
irisandClaude Opus 5 2756087e1c emulator: settle on GLES, and make every run say which adapter drew it
Iris's call, after the guest measurement: the emulator is a GLES rig and
nothing chases hardware Vulkan in it; the Vulkan path is covered by the
desktop build and by her phone.

Nothing had to be forced. The emulator has no hardware Vulkan at all --
its only Vulkan is SwiftShader in software -- and its GLES is the host's
real RX 7900 XT through virgl at ES 3.1, so iris's existing runtime
fallback lands there by itself. Verified end to end with an ordinary
debug APK: "no Backends(VULKAN|...) adapter on this device, falling back
to GLES", then "Android Emulator OpenGL ES Translator (virgl (AMD Radeon
RX 7900 XT ...)) (Gl, OpenGL ES 3.1 ...) on Backends(GL)". So the
emulator and the phone run the same binary, differing only in what it
finds -- which is the point, and `force-gles` must not be reintroduced to
arrange the emulator's backend.

What changed:
- The Android renderer logs the full adapter line at startup, as the
  desktop already did. Only the backend enum was logged, which cannot
  tell `Gl` on the host's GPU from `Gl` on SwiftShader; the same rule was
  written on one member of the pair and not the other.
- `run-bench.sh` prints that line before any number.
- build-apk.sh, Cargo.toml and RUST.md's "Vulkan in the emulator" carried
  the stale premise that the emulator defaults to software Vulkan and has
  to be steered off it. The recipes are marked superseded rather than
  deleted, since the record of why host Vulkan is unavailable is still
  worth having.
- Drive-by: an `#[allow]`-free clippy warning in android/platform.rs
  (useless JObject conversion) that only appears on the android target.

No Vulkan requirement was found in iris itself to remove: neither backend
asks for a feature, `device_limits()` stays at wgpu's defaults with the
compute fields zeroed, and both probe rather than expect an adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:12:21 -04:00
irisandClaude Opus 5 af7d5f3782 docs: what the emulator actually gives a GPU app, measured in the guest
`gpu-probe` cross-compiled with cargo-ndk and run inside a default
`emu up`: the guest's GL adapter is the host's real RX 7900 XT through
virgl, reporting OpenGL ES 3.1 with compute shaders, 1024 invocations per
workgroup and 64 KB of workgroup storage -- the same numbers the desktop
gets. `Backends::PRIMARY` still finds nothing, because the guest's only
Vulkan is SwiftShader.

So GPU acceleration in the emulator is not a thing to get working; it is
the default, and it is GLES. What is missing is GPU-accelerated Vulkan,
and the Venus retry on mesa 26.2.2 fails exactly as it did on 26.1.7 with
no newer emulator package to try.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:04:15 -04:00
iris db0a41a7cd gpu-probe: say whether each adapter has compute, not just the preferred one
Compute is a downlevel capability rather than a feature -- unconditional
on any Vulkan 1.0 device, GLES 3.1 and up -- so the question shadows and
blur raise is what the *weakest* adapter iris can fall back to offers.
Both here answer yes: Venus and virgl each report COMPUTE_SHADERS, 1024
invocations per workgroup and 64 KB of workgroup storage, virgl because
it is ES 3.2. The only no-compute machine in this project is the
emulator's SwiftShader software GL at ES 3.0.

RUST.md gains that table, why DRM native context is unrelated to it, and
what each of shadows/blur/paths actually needs -- only vello proper turns
the compute question on.
2026-09-08 12:55:47 -04:00
irisandClaude Opus 5 b9924e7617 iris: the GPU test's crash was the Vulkan loader unloading Mesa, not wgpu
`mask_sdf` SIGSEGVd after printing `test result: ok`, and the workaround
was to hand the device to the process with `mem::forget` on the reading
that "dropping a wgpu device on Venus segfaults". Every part of that
except the symptom was wrong.

`rigs/gpu-probe`'s new `teardown` bin is the experiment, one variable per
mode: the same open-and-close exits 0 on the main thread and SIGSEGVs on
a spawned one; it needs no GPU work and no device, only an instance; raw
`ash` does it with no wgpu involved at all; and keeping the instance
alive fixes it. Destroying the last VkInstance makes the loader dlclose
the ICD, and Mesa's ICD here registers a pthread_key_create destructor
into its own text without `-z nodelete`, so glibc calls it through
unmapped memory when the thread exits. libtest runs every #[test] on a
spawned thread, which is the whole reason this looked like a drop bug.
`VK_LOADER_DISABLE_DYNAMIC_LIBRARY_UNLOADING=1` confirms the mechanism.

So the fix is one `wgpu::Instance` for the process -- what wgpu asks for
anyway -- and the device, queue and everything else drop normally again.
The escape and its paragraph of reasons are gone.

Also: the machine-level graphics notes duplicated in docs/RUST.md,
run-headless.sh and two source comments now point at the
`this-machine-graphics` skill, which is the only copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:22:47 -04:00
irisandClaude Opus 5 f014e8d9cf docs/RUST.md: point at the this-machine-graphics skill
The GPU findings from 2026-09-08 would bite any project on this machine,
not just this one, so they are now a skill (AGENTS.md's own rule about
where a machine-wide lesson belongs). This section keeps the iris- and
port-specific half and names the skill for the rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:09:45 -04:00
irisandClaude Opus 5 0ccc444246 iris: cut test debug info, say which adapter drew, and log on the desktop
Three findings from one morning, all of them things that were invisible
rather than wrong. docs/RUST.md's two new sections have the full account.

**`cargo test --workspace` was taking half an hour, and it was debug
info.** rustc's default `debug = true`, times eight test binaries each
statically linking the whole wgpu + naga + winit + parley graph, means
every one of them gets a private copy of that graph's DWARF written into
it: the linkers for one run had written ~54 GB between them and were
still going at thirty minutes -- the worst single one 16.9 GB for one
test binary -- leaving an 88 GB target/. It was not CPU: the machine was
87% idle, and rust-lld's threads were in D state in btrfs
`handle_reserve_ticket`, blocked on space reservation at 83% full. So
`debug = "line-tables-only"` on both `profile.dev` and `profile.test` --
both, because `cargo test` builds dependencies under one and the test
targets under the other. Cold, with all 19 suites run: 69 s and a 3.7 GB
target. Backtraces keep file and line; `RUSTFLAGS="-C debuginfo=2"` per
run buys back variable inspection when a debugger actually needs it.

**The desktop had no logger at all**, so every `log::` call on that side
went to `log`'s no-op default -- including the GLES fallback warning
added hours earlier. `DefaultApp::run` installs a stderr logger
(`src/default/logging.rs`, no new dependency: a level and a line is a
page of code against env_logger plus its filter dialect), and the
renderer now says which adapter won at `info`. That line is the point:
with a silent fallback, a layer-2 screenshot rendered by llvmpipe and one
rendered by the host's GPU are the same PNG, and which one it was is
exactly what the screenshot is being taken to judge.

**`tests/mask_sdf.rs` is a render pass now, not a compute pass.** It
asked for `adapter.limits()` because `iris_core::device_limits()`
deliberately zeroes the six `max_compute_*` fields -- a decision on
record since 2026-09-05, which this quietly worked around instead of
following. It now asks for what iris asks for and calls the function from
the fragment stage, where the renderer calls it. The compute pass was
*not* why it crashed, and the record should not say it was: the rewrite
crashes identically. What the crash is: dropping a wgpu device on this
VM's Venus adapter segfaults, after the test has produced its answer
(worst CPU/shader disagreement 5.8e-6). Narrowed -- plain Vulkan creating
and destroying five VkDevices on the same adapter is clean, and the same
binary with Vulkan hidden falls back to GL and exits clean. Worked around
at `Gpu::leak`, with the reason and the delete-me condition written
there.

`rigs/virtgpu-probe` is the new rig behind the Venus half: which capsets
the host offers (0x16 -- VIRGL, VIRGL2, VENUS; no capset 6, so no DRM
native context without host-side work), whether the device has compute
(it does: 1024 invocations/workgroup -- the "no compute" finding on
record is about the Android emulator's SwiftShader, a different machine),
and whether plain Vulkan teardown is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:06:02 -04:00
irisandClaude Opus 5 c6da735134 docs/RUST.md: the ABI-cache half of the build-apk.sh box is done (4f6ec3a)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:17:15 -04:00
irisandClaude Opus 5 4f6ec3a900 iris-android-app: clear Gradle's native-libs cache, so an ABI switch takes
`build-apk.sh` already removed `app/src/main/jniLibs` before each build,
with its own comment saying why. It does not reach Gradle's own copy:
`mergeReleaseNativeLibs` is up to date against its cached inputs, so a
build that switches ABI packages the previous one. An `--abi x86_64`
release APK containing `lib/arm64-v8a/libmain.so` installed fine and
aborted at startup with `Could not get adapter!: NotFound {
active_backends: VULKAN }` under libndk_translation -- which reads
exactly like the phone's own Vulkan problem and is nothing of the kind.
It cost an hour on 2026-09-07 and was written down rather than fixed.

Scoped to the three native-lib directories rather than all of
app/build, so an ABI change costs the native merge and not the whole
Gradle build. Verified on the case that produced it: this checkout held
an x86_64 libmain.so from emulator work, and `./build-apk.sh release
--abi arm64-v8a` produced an APK whose only .so is
lib/arm64-v8a/libmain.so (7,518,840 bytes) -- that APK is
ai-app-bench a012ff9.

docs/RUST.md's queue box keeps its second half open: the 648 MB debug
bench APK still will not install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:17:07 -04:00
irisandClaude Opus 5 38bf6309cb iris: a mask is a shape, not a rectangle -- .masked_by, and touch obeys it
Iris, on the code fence: "the code block scrolling currently masks in an
inner rectangle. Ideally masks should have a shape associated with them,
rounded rectangle being one of them ... so that the mask becomes the
parent container with rounded edges. Make sure alpha works properly with
it, eg. on the corners where alpha should be decreased / multiplied."

`Mask` is now `{ primitive, parent }` -- the slot of a primitive already
written, plus the mask this one nests inside. The fragment stage
evaluates that primitive's own coverage at the masked pixel, through the
same `rounded_rect_coverage` a drawn rect goes through, and multiplies it
into the alpha along the whole `parent` chain. Nothing about the shape is
copied, so a rounded container's corner and its children's clipped corner
are one piece of arithmetic and cannot drift; two nested feathers dim a
pixel twice, which is the multiply she asked for.

`.masked()` is unchanged for callers: it writes an undrawn rect
(`Drawn::No`/`NOT_DRAWN` -- owned, moved, resized and freed like any
other primitive, simply never rasterized) and points at that, so square
clipping is the same mechanism rather than a special case. New
`.masked_by(shape)` draws `shape` behind the content in its own layer and
clips to the first primitive it drew, with no radius written twice; it
replaces `.masked().background(w)`, which drew both and clipped to the
box. `transcript-ui`'s `BlockFrame::Verbatim` is the first caller.

Hit-testing applies the shape (`SensorUi::run_sensors` ->
`UiRenderState::mask_admits`, coverage above one half, which is where the
drawn edge is), as well as the widget's own box -- the two ask different
questions and both have to hold. `primitive_corners` is a floor-for-floor
transliteration of the shader's `corners_of`, not `region.to_px()`: the
phone's 2.55 density puts nothing on a whole pixel, and skipping the
rounding disagrees with the pixels by up to one along each edge.

A mask's shape must be a rect, asserted by name in `set_mask_to`. A glyph
would need a CPU-side alpha plane before the hit test could agree with
the shader, and a standalone image a bind-group switch the fragment stage
cannot make. So no texture mask exists; the branch where one would go is
in both copies of `mask_coverage`. docs/LAYOUT.md's section end lists this
and the three other places the code is narrower than the design.

Tests. Layer 1, `layout_tests.rs`: the child's coverage swept across the
container's corner arc equals the container's own exactly; nested masks
multiply rather than intersect, asserted where both feathers are partial,
which is the only place the two differ; a press in a rounded-away corner
misses while one inside the curve and one on a straight edge hit; and
`a_plain_mask_still_clips_to_a_square_box`, the half this had no reason to
touch. The first version of the corner test swept the straight chord
between the arc's ends, which lies inside the circle everywhere -- it
proved nothing and said so, which is why it counts both sides now.

`iris/tests/mask_sdf.rs` is the only test here that needs a GPU: it lifts
`distance_from_rect` and `rounded_rect_coverage` out of
`iris_core::SHAPE_SHADER` by name -- lifted, not copied, since a copy
would be edited alongside the shader -- and runs them in a compute pass
over ~200k points at five radii against `iris_core::rounded_rect_coverage`.
Worst disagreement under 1e-5; the negative control (`+ 0.01` inside the
shader's smoothstep) fails it at 0.03.

Layer 2 for looking: `./run-headless.sh phone --phone --shot /tmp/mask.png
--seconds 6 -- -p transcript-fixture` draws the fixture's horizontally
scrolled code fence clipped on the curve at both top corners.

Two things found on the way and fixed here:

- The winit backend had the defect the Android one was fixed for in
  85869d0 -- `Backends::PRIMARY` and an `.expect` on the adapter. This
  VM's Venus device disappears when the host runs out of virgl contexts,
  which happened mid-task, and layer 2 aborted with `Could not get
  adapter!` while GL sat there working. It probes and rebuilds the
  instance on `Backends::GL` exactly as Android does now, and the request
  names the backends it tried. The rule had been written on one member of
  a set of two.
- `active_primitive_count` counted mask shapes, so `iris::frame`'s
  `primitives=` -- a number Iris reads off a phone report as "how much is
  on screen" -- would have gained one per masked widget.

`widget_trait!` now accepts a `///` doc comment on its functions, since
`masked_by` is public API and rustdoc is where a contract is read.

docs/LAYOUT.md, docs/RUST.md (both queue boxes, the commands, and where
the GPU test sits among the three layers), docs/IRIS.md and
docs/IRIS_TODO.md ("Masks defined relative to each other", now closed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 02:18:57 -04:00
irisandClaude Fable 5.1 3eb0e033d5 docs: tick report hygiene and bench header (commits 7485d78, b8ea723)
Both docs/IRIS_TODO.md's night bullets and docs/RUST.md's queue items
covered by the two client-core/iris-android-app commits above.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:26:49 -04:00
irisandClaude Fable 5.1 b8ea723718 iris-android-app: Copy report always copies; restore the header's text size
Two of the phone's 2026-09-07 night reports (docs/IRIS_TODO.md):

Copy report used to silently decline ("nothing to copy -- run the
benchmark first") whenever no benchmark had run yet, which read on the
phone as the button being unhittable until Diagnostics was pressed first
-- UI_RULES's "a failure is reported where it happened" failure, since it
declined with no visible effect. It now always copies something: with no
benchmark run yet it copies the diagnostics pane's own text instead (which
needs no prior button press either), with a first line saying so, and in
every case appends the ring's tail (LogRing::tail_text,
COPY_REPORT_TAIL_LINES lines, previous commit) instead of the whole ring,
which was the other half of "causes a lot of lag" pasting it into a
message box. app_log.rs wires iris::diagnostics::trace_enabled into the
ring filter that commit added.

The header's four controls no longer fit one row at HEADER_TEXT = 18, and
a previous agent had shrunk it to 13 to make room -- exactly what
UI_RULES forbids (never shrink text to fit a layout). Restored to 18 and
split bench_controls into two rows instead (run+copy, diagnostics+trace),
doubling the header's own height rather than the outer layout's reserved
space (top_bar already sizes to its own content). Checked on this
checkout's emulator: ui-trace's --field box shows two clean, non-
overlapping rows, and a screenshot shows the restored size reading
clearly; a Copy report tap with nothing run yet now logs "copied to
clipboard" instead of declining.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:26:27 -04:00
irisandClaude Fable 5.1 7485d78d50 client-core: filter the ring's Debug/Trace lines to iris's own targets
Iris's phone report (docs/IRIS_TODO.md, 2026-09-07 night): the ring held
1339 lines and dropped 4050 more, almost all of it naga::front/wgpu_core/
jni logging at Debug unconditionally, because RingLogger accepted every
target at whatever level `log`'s own max was set to. The trace gate added
in 992c472 only covers iris's own debug! call sites, not a dependency's.

ring_accepts() is the one filter, applied in RingLogger::log rather than
per callsite: Info and above always rings, from anywhere (a dependency's
real warning is worth keeping); Debug and Trace ring only from `iris`/
`client_core` targets, and only while tracing is on. Tracing itself is
`iris::diagnostics::trace_enabled`, passed into RingLogger as a plain
`fn() -> bool` rather than called directly, since client-core sits below
iris and must not depend on it -- the same reason `inner` (the platform
logger) is already injected rather than chosen here.

Also adds LogRing::tail_text and COPY_REPORT_TAIL_LINES (150, named and
reasoned at the constant) for the next commit's Copy report trim.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:26:17 -04:00
iris b87f5a597e iris: a finger put down on a moving list catches it at that sample
Iris, from the phone (docs/IRIS_TODO.md, 2026-09-07 night): "sometimes
when I try to catch it while it's still moving (particularly if I drag)
then it fails to stop & snap to where finger is." The fling did stop on
the down -- `Selection::drag` has cancelled it since the fling landed --
but the *gesture* then went through `DRAG_SLOP` like any other press, so
for the first few frames the finger was down and the content under it
did not move. Compose does not do that: `scrollable`'s
`startDragImmediately` is `isScrollInProgress`, and the drag starts on
the DOWN with no slop.

So `DragArbiter::press_start` takes a `PressState` -- what the target
looked like when the press landed, `already_selected` and `scrolling` --
and a press on moving content enters `Panning` immediately. A catch that
is released without ever moving is `Released(None)`: not a `Tapped`,
because Compose consumes that DOWN and no click detector under it sees
the gesture, so stopping a fling must not also follow the link it landed
on; and not a velocity, because there is none to hand on. The moment it
moves anything it is an ordinary pan release again and flings normally.

`DragGesture::starts_press` is the one rule for "this frame opens a
press", read by `handle` and by `Selection::drag` -- which has to prepare
its list (cancel the fling, report whether there was one) on exactly the
frames `handle` will call `press_start` on, including the recovery frames
where no `PressStart` ever arrived.

It deliberately does **not** special-case `PressStart` to true, which is
the defect the layer-1 test found: one touch-down reaches every sensor
under the finger, and a transcript row's block and the tool row
containing it drive the same shared `DragGesture`, so `handle` sees one
`PressStart` twice. Restarting on the second delivery re-read
`PressState` after the first had already acted on it -- the fling was
cancelled by then, `scrolling` came back false, and every catch quietly
became an ordinary slop-waiting press again.

Tests. Layer 1, `transcript-fixture/tests/catch_a_fling.rs`: the
recorded 120Hz flick, 150ms of fling, then a down and three 2px moves --
the content tracks the finger sample for sample
(`a_press_on_a_flinging_list_pins_the_content_to_the_finger`, which
fails at the parent commit with "the content 0.0px"); a catch released
without moving neither taps nor flings; and the half this had no reason
to touch, `the_same_small_drag_on_a_settled_list_moves_nothing` -- 6px
total is inside `DRAG_SLOP`, so making every press pin the content would
pass the first test and take the slop away from every ordinary one.
Unit, in `sense.rs`: the catch pans from the first sample, the same
press on settled content stays undecided, a catch that drags still
flings, and the double-delivered `PressStart` stays one press.
2026-09-07 22:18:16 -04:00
irisandClaude Fable 5.1 80a75c128e docs/RUST.md: who owns the killed agents' diff, and the stale worktree note
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:08:35 -04:00
irisandClaude Fable 5.1 50e69995b6 docs/RUST.md: the emulator crash loop was the missing GLES fallback, with the panic-hook note
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:50:06 -04:00
irisandClaude Fable 5.1 85869d02f8 iris: the Android renderer falls back to GLES, and every failure reports
The bench app crash-looped on this checkout's emulator with the default
features (RUST.md's queue item). Not the surface lifecycle and not "once
backgrounded": a build without `force-gles` never got a first frame.
`AndroidRenderer::new` asked wgpu for `Backends::PRIMARY`, which does not
contain `GL`, and this emulator advertises a Vulkan ICD with no adapter
behind it -- `NotFound { active_backends: VULKAN, no_adapter_backends:
VULKAN, supported_backends: VULKAN | GL }`, `.expect`ed, so SIGABRT, so
the launcher restarts it. iris was refusing a device whose only usable
adapter is a GLES one.

It now probes for a `PRIMARY` adapter and rebuilds the instance on
`Backends::GL` when there is none. The probe runs on an instance that
never touches the window on purpose: **an Android window can be
connected to one graphics API only**, so one instance carrying both
backends fails worse -- measured here on the way to this fix, Vulkan's
`vkCreateAndroidSurfaceKHR` claims the window in `create_surface` and
the GLES surface from the same window then reports `In
Surface::configure / Invalid surface`, aborting a frame later in
`Surface::get_current_texture_view`. Vulkan still wins wherever it has
an adapter (`PowerPreference::None` does not sort, and Vulkan is
enumerated first), so nothing changes on the phone.

Second half, the same rule applied to the whole set: the surface,
adapter and device requests all report through the `Result<Self,
String>` this function already returns, where two of the three used to
panic. `surface_changed` puts that string on screen and in the log
ring, which is what the Result was added for.

Emulator evidence (API 36 x86_64, debug): after, `iris renderer: no
Backends(VULKAN | METAL | DX12 | BROWSER_WEBGPU) adapter on this
device, falling back to GLES` then `new renderer built (Gl)` and
frames. Clean on both the default and a `force-gles` build for the
cases this had no reason to touch: two background/return cycles,
rotation there and back (the `already_live=true` reuse branch), a
background/return after the rotation, and cold starts. Vulkan could not
be exercised here -- that this emulator has no Vulkan adapter is the
defect itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:50:02 -04:00
irisandClaude Fable 5.1 f99ae4c366 iris-android-app: a panic hook, so an abort says something Iris can read
Checked before writing anything: under `panic = "abort"` (this crate's
Cargo.toml) a panic's message reaches the tombstone's `Abort message`
and nowhere else -- not `log`, so not `client_core::log_ring`, so not
Dev Updater's Runtime tab. That tab is the only surface Iris has on a
phone with no `adb`, so every `assert!` and `expect!` in these builds
has been failing silently as far as she is concerned; the adapter crash
fixed in the next commit looked like the app simply relaunching.

`install_panic_hook` (called from `app_log::install`) writes the
message and its location at `error` level. The ring is memory only and
the process is about to die, so it also writes `last-panic.txt` in the
app's private directory; `set_crash_dir`, called from
`nativeSetFilesDir`, replays that into the ring at `error` level on the
next start and deletes it. A crash loop therefore explains itself in
the run that is still up, which is the run somebody can look at.

Verified on this checkout's emulator against the unfixed renderer:
`iris panic at .../render.rs:140:14: Could not get adapter!: NotFound
{...}` on the run that died, and `iris app log: the previous run died
-- ...` on the next one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:49:45 -04:00
irisandClaude Fable 5.1 203f53470c iris: one primitive arena all layers share, with placement in a storage buffer
A mask is about to reference a primitive already drawn and evaluate it at
the masked pixel (docs/LAYOUT.md's "Masks with a shape"), which the data
layout could not answer: a primitive's placement lived in its layer's
*vertex* buffer, invisible to the fragment stage, and `rects`/`glyphs`
were per layer too -- so a mask whose shape is a rounded container in one
layer, clipping content a `Stack` put in another, would have read the
wrong layer's rect with nothing on screen to say so.

So the instances and the per-primitive data become one arena
(`UiRenderState::primitives`), bound once per frame; a layer keeps only
its draw *order*, which is what its vertex buffer now is -- one `u32`
slot per instance instead of eight attributes. The vertex stage reads the
placement it is drawing from `instances[slot]`; the fragment stage can
read any other primitive's from the same buffer, which is what the mask
work needs and the reason there is no second copy for masks.

Arena slots are stable (nothing is compacted), so a `Mask` can hold one
across frames. A slot freed during a redraw is therefore not reusable
until every layer's order has been compacted around it -- otherwise the
reused slot would draw twice, once through the stale order entry -- which
is what `Primitives::freed` and `UiRenderState::apply_free` are. That
compaction moved out of `UiRenderNode::update` into `UiRenderState::
update`: it is bookkeeping over `active`, not GPU work, and the harness
(which has no renderer) needs it too.

Same 164 tests, the `--phone` screenshot unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:21:55 -04:00
irisandClaude Fable 5.1 b38e797db3 docs: phone report 2026-09-07 night -- catching a fling, silent Copy report, third-party debug flooding the ring
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:18:29 -04:00
irisandClaude Fable 5.1 92985ba8e3 iris/Cargo.lock: the log entry for iris-android-app regenerated after the uploader's removal
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:09:35 -04:00
irisandClaude Fable 5.1 181ba64606 docs/REVIEW-2026-09-07.md: every finding's status after the fix pass
13 fixed, 6 moot or deferred, 2 not done on purpose. Each finding gets its
own Status line in place rather than a summary at the end, so a reader who
arrives at a finding sees what happened to it; the header carries the
counts and the six commits.

The moot ones are all in the phone-logging route 06b8a1f deleted (D2's
unbounded `POST /client-log` body, D3's silently dropped lines, R3's three
copies of one wire contract, R4's `build.rs`, and the `client_log_time`
duplication) -- the app hands its log to Dev Updater through an on-device
ContentProvider now, so there is nothing left to bound or share. Two more
are deferred to the devlog agent because `iris/android-app/**` and
`client-core/src/log_ring.rs` were open under it this pass.

The two left undone are deliberate. R2 (a mask clips drawing but not
hit-testing) waits on docs/LAYOUT.md's mask redesign, since intersecting
a chain in `resolved_region` now would be a second mechanism to unpick.
R6 is a look-at-it-on-the-phone item and no build in this VM is evidence
about her device's font set.

Full checks on the tree as pulled: `cargo fmt --check` clean in `iris/`,
`server/`, `client-core/` and `event-model/`; `cargo clippy --workspace
--all-targets` exit 0 in `iris/` and `server/` (the only line is the
`future-incompatibilities` note about naga/wgpu/winit, which predates
this pass); `cargo test --workspace` 165 in `iris/`, 160 in `server/` and
157 in `client-core/`, no failures. The one thing not run is a real
device build -- `cargo ndk -t x86_64 -P 29 check -p iris` is clean, but
`-p iris-android-app` is the devlog agent's tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:09:02 -04:00
irisandClaude Fable 5.1 a6a100edc6 iris: the chain bound is named for the walk, and two nits from the review
docs/REVIEW-2026-09-07.md's rule finding on `MOVE_CHAIN_LIMIT` plus both
nits.

`MOVE_CHAIN_LIMIT` bounds two different parent walks -- move offsets in
the vertex stage and `Mask::parent` in the fragment stage -- under a name
that says one, and the shader's own comment beside it already called it
"the bound on the parent walk". Renamed to `PARENT_CHAIN_LIMIT` in both
files at once (the constant has no other users), with the doc saying
which two chains it governs.

`DragGesture`'s release computed `self.velocity.velocity()` twice, once
for the outcome and once for the `iris drag release:` line -- a full Lsq2
fit each. Once now, into a local both read.

`transcript-ui`'s `selection.rs` called `ui.ui_mut().animate(id)` even
when `List::fling` had bailed (Compose's `|v| <= 1.0`, or no anchor), so
a frame was asked to advance an animation known not to exist. It is
behind `is_scrolling()` now, which is the same answer `fling` itself
reached. `phone_screen.rs`'s recorded flick still flings, which is the
half that says the guard did not turn a working release off.

Verified: `cargo test --lib -p iris` (104) and `-p transcript-fixture`
(12), fmt and clippy clean, and layer 2 (`run-headless.sh phone --phone`)
still renders with the mask chain intact -- code fences clipped to their
rows, the list clipped at the composer -- which is what the wgsl rename
needed looking at rather than compiling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:05:55 -04:00
irisandClaude Fable 5.1 ff1d6ea932 iris: a degenerate fit has no solution, and the desktop follows a display's density
Two of docs/REVIEW-2026-09-07.md's risks.

**R7.** `poly_fit_least_squares` clamped a near-zero basis-vector norm
(`1.0 / dot(..).sqrt().max(1e-6)`) where Compose's `polyFitLeastSquares`
bails: below `0.000001f` the vectors are linearly dependent and there is
no solution. Clamping reached the solve with a `q` row of zeros and a
zero on `r`'s diagonal, produced `[NaN, NaN, NaN]`, and was rescued only
by the caller's `is_finite` check -- working, but by accident, and not
what the source it is transcribed from does. It returns `Option` now and
`velocity()` answers 0 on `None`.
`a_fit_through_linearly_dependent_points_has_no_solution` reports
`Some([NaN, NaN, NaN])` with the clamp back in place. Three samples at
one instant is exactly what the input clock produced before 2ec0fee, so
this is the second half of the same fault.

**R5.** `WindowEvent::ScaleFactorChanged` was unhandled, so dragging the
window to a display with a different scale left every `Len::dp` and every
rasterised glyph at the density the window opened on. It now re-reads
`content_scale` -- through that function rather than off the event, so
`IRIS_SCALE` still pins `--phone`'s density instead of following the
monitor -- and sets both copies. `UiRenderState::set_density` marks the
tree for a full redraw when the value actually changes, because
`Text::shape` keys its cache on `(attrs, width, density)` and nothing
else would ask for those glyphs again. Invisible on this machine (every
display here is 1.0), which is why the review asked for it in writing.

Verified: `cargo test --lib -p iris` (104), `cargo test -p
transcript-fixture` (12), `cargo ndk check -p iris`, fmt and clippy
clean, and layer 2 (`run-headless.sh phone --phone --replay
flick-120hz.touch --shot`) still draws and still clips at the composer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:03:43 -04:00
irisandClaude Fable 5.1 2b20bb2c91 docs/RUST.md: queue -- bench header type shrunk to fit, emulator crash loop after backgrounding
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:01:27 -04:00
irisandClaude Fable 5.1 3c80d9d696 iris bench: a Trace switch for the input/frame diagnostics, and the report says when it was on
`iris::diagnostics::set_trace` landed with nothing to press it. It is the
bench header's fourth control now, reading `Trace off` or `Trace on` --
a toggle whose own appearance never changes is a button that looks like
it did nothing. Its accessibility label stays the fixed "Trace input and
frames", because that is what `run-bench.sh` and `ui-trace --do "tap
'...'"` find it by and a control that renames itself when pressed is one
no script can find twice. Pressing it rebuilds the header and shows the
diagnostics pane, so the state is on screen at the moment of the press.

Both reports carry `trace_line`, from the flag read at the *start* of
what is being reported as well as at the end: the switch is on screen
while a benchmark runs, so "somebody moved it half way through" is a
state that happens, and reported as either "on" or "off" it would be a
confident sentence about a log covering half the run.

The row's type size is one constant for all four labels and drops from
18 to 13: with a fourth control the labels overlapped each other on a
1080px screen. Shrinking one label to fit is what the UI rules forbid;
resizing the row is a layout decision and all four still match.

Checked on the emulator: the switch flips its own text and colour, the
pane reads "input/frame trace: on", and `iris::frame`/`iris::input`
lines appear in the ring only after it is pressed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:58:58 -04:00
irisandClaude Fable 5.1 06b8a1f4b0 The app hands its log to Dev Updater on the phone, not through ai-server
Iris's call once the upload route was working: put it in Dev Updater
properly. So the app now exposes its own ring through a ContentProvider
at `<applicationId>.devlog` -- Dev Updater's contract, written down in
that project's README, not something invented here -- and Dev Updater's
phone app reads it on the same device and forwards it to its own build
machine. No tunnel, no token, no second enrolment, and any app that
server delivers can implement the same and get the same Runtime tab.

`DevLogProvider.java` plus `devlog.rs` are the platform glue only: a flat
`String[]` across JNI, a `MatrixCursor` on the Java side, and
`nativeReady` telling Rust the authority the provider actually
registered, so the Diagnostics pane can name somewhere a reader can
query rather than composing a guess. `LogRing::newest_seq()` is the one
addition in `client-core`: an in-memory ring starts again at zero, so it
is what lets a reader notice the process restarted instead of silently
skipping everything since.

Deleted with it, so there is one mechanism: `client_core::log_upload`,
`POST /client-log` on ai-server, the `AI_APP_LOG_*` baking (which left
`build.rs` with nothing to do), and the uploader on both Android
clients. Kept: the ring, `RingLogger`, `install_process_logger`, and the
Diagnostics line -- whose second half is now `devlog provider:
content://<authority>`.

Verified end to end on this checkout's emulator: iris's own
`iris::android::view` startup lines read out of the provider by the
shell, forwarded by Dev Updater's Runtime tab, and served back from
`GET /apps/android-app/components/app/logs?kind=runtime`. A component
whose package has no provider says so in as many words.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:58:48 -04:00
irisandClaude Fable 5.1 e10582a2cd iris: three layer-1 tests that could not fail in the direction the bug goes
docs/REVIEW-2026-09-07.md's T1, T2 and T3. Each was confirmed by breaking
its subject on purpose and watching the new assertion fire, and each of
those breaks is recorded beside the assertion.

**T1** (`phone_screen.rs`) bounded the fling's duration with
`FlingCalculator::new(PHONE_SCALE).duration(velocity)` -- the calculator
under test -- and only from above, so it could fail when a fling ran too
long and never when one stopped dead, which is the symptom Iris actually
reported. The companion `assert_ne!(before, after)` passes on one pixel of
travel. It now takes both bounds from `fling_spline_reference.py`, which
gains this case's own line (`density=2.55 v=15250.0: distance=11057.424px
duration=2.0716s`), and measures travel in pixels from a row's own
on-screen extent -- 10527px against the reference's 11057, the 5%
shortfall being the frames a tracked row leaves the screen on. Scaling
`tick_fling`'s elapsed by 1000 reports "stopped after 8ms"; scaling its
delta by 0.01 reports "travelled 111px".

**T2** (`top_edge.rs`) asserted the per-row box only on the return leg,
so a regression that drew rows in the wrong place while travelling
*backwards* was checked by the row count alone. The first leg still
cannot assert it (an unmeasured row has to be drawn to be measured), so
there is now a third leg -- back again, every height known. Widening
`intersects_viewport` downwards passes all 40 forward steps and fails at
"back 6", which is the leg that did not exist.

**T3** (`top_edge.rs`) asserted a mask exists and sits inside the list's
box, never that any row primitive references it, so a broken
`Mask::parent` chain -- what d507ae4 introduced -- left it green while a
code fence drew unclipped. It now walks every row primitive's chain and
requires the list's own mask slot on it (and rejects a chain that loops).
Forcing `Painter::set_mask`'s `parent` to `NONE` fails it with "clips to
[Id(1)], a chain that never reaches the list's own mask Id(0)".

Verified: `cargo test -p transcript-fixture` (12) and `cargo test --lib -p
iris` (103) pass, fmt and clippy clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:58:35 -04:00
irisandClaude Fable 5.1 551c01398f iris: the guards against silently wrong output survive into release
docs/REVIEW-2026-09-07.md's R1. Every invariant guard added on 2026-09-07
was a `debug_assert!`, and every build anybody runs on this project is
release -- the bench APK must be (the debug `libmain.so` is 325 MB and
will not install) and Iris's phone gets release too. So a `List` drawn
without a mask painted over its surroundings again, in exactly the build
the fault was found in, with nothing saying so.

Promoted to `assert!`, each O(1) or a handful per *draw* and each
protecting against output that is wrong on screen with no other symptom:
`List::draw`'s `painter.is_masked()`, `List`'s `extents`-are-on-screen
check, `Painter::set_mask`'s doubled-call check (the second call replaces
rather than nests, i.e. an unclipped widget), `Painter::glyphs`'s atlas
generation (glyphs sampled from coordinates now holding other letters),
and `List::fling`'s finiteness (one comparison per gesture; NaN
propagates into `deceleration_for`'s `ln()` and the fling never settles).

Left as `debug_assert!` and now saying so in a comment: `List::place`'s
slot-exists precondition (once per row placed per frame, and its release
failure is the `.expect` below rather than something wrong on screen) and
`poly_fit_least_squares`'s two preconditions (run on every velocity query,
with `MIN_SAMPLE_SIZE` and the `is_finite` check giving release a defined
outcome either way). `PointerClock::sample`'s ordering assert was already
annotated in 2ec0fee for the same reason.

Verified: `cargo test --lib -p iris` (103) and `cargo test -p
transcript-fixture` (12) pass in both debug *and* `--release`, which is
what says the promoted asserts do not fire on a real replayed flick;
fmt, clippy and `cargo ndk check -p iris` clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:52:45 -04:00
irisandClaude Fable 5.1 7e79ec11e0 docs: the fling's "before" velocity is what velocity_reference.py prints, 12250 and 12500
docs/REVIEW-2026-09-07.md's D5. Four places quoted 11750 px/s as the old
average estimator's answer -- for `flick-120hz.touch` *and* for the
press-plus-one-move-frame set, which are different sample sets, and one
number in both rows is the tell. `iris/benches/velocity_reference.py`,
which the same section says every number below it comes from, prints
12250 for the recording and 12500 for the two-sample set, and
`sense.rs:1406` already had the 12250.

Half of where 11750 came from is recoverable and is written down beside
the table: it is the recording's 196 px over 16.68 ms, a 60 Hz frame
rather than the 16 ms span the file itself records. That explains the
flick row; the other row was copied from it. The 1.30x ratio derived from
it becomes 1.24x.

Also settles the second disagreement about the same experiment (the
review's rule finding on the negative control): `sense.rs`'s doc comment
claimed reverting `velocity` to total-over-span fails "exactly this one,
the flick recording, and phone_screen.rs" while RUST.md said seven. Run
again today with the revert in place: seven in `-p iris` (the flick
recording, the accelerating flick, the horizon, the stopped finger, the
minimum sample count, both `drag_gesture` flick tests) plus
`phone_screen.rs`'s flick, everything else green. RUST.md was right and
the comment now says the same thing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:43:23 -04:00
irisandClaude Fable 5.1 2ec0fee84c iris: the input clock anchors on the first event's oldest sample, not its own time
docs/REVIEW-2026-09-07.md's D4. `on_touch_event` took its one anchor as
`(Instant::now(), event.event_time_nanos())` from the first MotionEvent the
view ever sees, and dated every later sample as `anchor_at + (sample -
anchor).max(0)`. An event's historical samples are by definition *older*
than its own event_time, so if that first event is a Move -- the Down went
to another view, or the view was attached mid-gesture -- its whole batch
clamps onto one instant: three samples at the same time make the Lsq2 fit
degenerate and the flick reads 0 px/s. In a debug build the ordering
debug_assert fired first, and it was comparing against `anchor_nanos`,
a value from a different event, so it was also the wrong comparison for
the first sample of every later event.

The arithmetic moves into `sense::PointerClock`, which anchors at
`now - (event_time - oldest_sample)` and carries the last sample seen
across events, so `sample()`'s ordering assert compares against the
previous event's last sample. It lives in `sense` rather than in the
android backend because `iris::android` is cfg'd out everywhere but the
device, and this is exactly the arithmetic that wanted a test off one:
`the_first_events_batched_samples_are_dated_apart` reports [0ns, 0ns, 0ns]
against the old anchoring.

The assert stays a `debug_assert!` and now says why in a comment: it runs
once per touch sample, hundreds a second on a batching 120Hz screen, and a
mis-ordered sample degrades a velocity rather than drawing something wrong.

Also drops the stale reference to `VelocityTracker::add_sample` in the
comment above it (the review's rule finding); the method is `add_position`.

Verified: `cargo test --lib -p iris` and `cargo ndk -t x86_64 -P 29 check
-p iris` clean, fmt and clippy clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:36:41 -04:00
irisandClaude Fable 5.1 992c472975 iris: iris::input/iris::frame diagnostics, and gating the four debug! lines that already drowned the ring
Iris asked for a button to copy raw input events and per-frame timings
through the same report Copy report already produces. sense::log_input_event
(one line per platform pointer sample, historical samples inline on
Android) and diagnostics::log_frame (one line per frame: frame number,
frame clock, time since last input, layout/draw durations, redraw kind,
primitives on screen, animating) both land under iris::diagnostics's
trace_enabled() gate, off by default since the ring is 2000 lines/256KiB
and either target at 120Hz fills it in seconds. report_to_touch.py turns
a report's iris::input lines back into a .touch file for harness/desktop
replay, round-tripped in transcript-fixture's input_log_roundtrip test.

Folds in docs/REVIEW-2026-09-07.md's D1: four older per-frame debug!
lines (android::view's two render() lines, list.rs's fling tick,
text/mod.rs's text render) were unconditional at Debug and, with the
ring's RingLogger recording everything the app's Debug install lets
through regardless of target, filled it before Copy report ever saw
anything else. All four (and sense.rs's drag-release-samples line) are
now behind the same gate. The same test proves both directions: tracing
off leaves zero Debug lines from a replayed flick, tracing on produces
the expected iris::input/iris::frame lines with real durations.

Not wired to a Diagnostics-pane button: bench_client.rs is open under
another agent. set_trace(bool) is the whole surface a control needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:48:48 -04:00
irisandClaude Fable 5.1 729098756d docs: the CA travels in the enrol link, and why not the two alternatives
DECISIONS.md gets the decision with both rejected options and what the
longer link measures (89 -> 652 bytes, a 45x23 QR -> 93x47), RUST.md ticks
the enrolment queue item and marks the log-upload route superseded rather
than editing it, and IRIS.md says what changed for anyone building the
Android app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:47:48 -04:00
irisandClaude Fable 5.1 d8562d96a3 iris android app: told which server by an enrol link, not by its build
The APK is cross-compiled here and run against the server on the host, so
everything build.rs baked in (AI_APP_TRANSCRIPT_HOST/_PORT/_TOKEN and this
machine's CA) was good for exactly the pair that built it -- and a token in
a delivered artifact besides. MainActivity registers aiapp://enroll, hands
the URI and the app's private files directory to Rust, and
client_core::config stores it 0600; transcript_client reads it afresh per
transport, so opening a new link repoints a running app.

Diagnostics says which of three things is true, because they want different
actions: 'enrolled: host:port', 'not enrolled -- open the enrol link from
Dev Updater', and 'enrolment unreadable: ...' for the case nothing could be
found out. The last is why status() has an Unknown arm at all.

ui-sandbox.sh's printed enrol command now carries the CA, which is what
makes it work for an app with no baked copy.

Verified on this checkout's emulator: fresh install reads 'not enrolled',
the intent enrols (log: 'enrolled with 10.0.2.2:8519', enrollment.json
-rw-------), Diagnostics then reads 'enrolled: 10.0.2.2:8519', and the CA
reconstructed from that link is byte-identical to the machine's ca.pem and
validates the server over curl. Android offered the chooser between this
app and the Compose one, which is the intended behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:45:51 -04:00
irisandClaude Fable 5.1 22210a42f5 docs: review of 2026-09-07's work -- 5 defects, 7 risks, 3 tests that cannot fail
Read-only review of ba2afba..origin/rustify (the fling spline and Lsq2
velocity, list culling/clamp/anchor re-homing, nested masks, the headless
harness, insets/targetSdk, platform fonts, and the client-core log ring
with POST /client-log).

The three that matter most: the app's own log ring is installed at
LevelFilter::Debug while the same day added three ungated per-frame
`log::debug!` callsites, so the 2000-line ring wraps in under ten seconds
and the route built to get Iris's logs to her carries frame spam instead;
POST /client-log inherits the router's 32 MiB body limit with no
per-message or rate cap, so an authenticated client can fill the host's
disk through ai-server's runtime log; and every invariant added today is
a `debug_assert!` while the phone and the bench APK are both release
builds, so none of the new guards can fire where the defects were found.

Also: the input clock anchors on the first MotionEvent's own event_time,
so that event's historical samples date before the anchor and are
silently clamped onto one instant; the "before" fling velocity quoted in
four docs (11750 px/s) is not what velocity_reference.py prints (12250);
masks clip drawing but not hit-testing, so a straddling row is now
invisible above the list and still tappable through the header.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:38:43 -04:00
irisandClaude Fable 5.1 ade572973a enrolment carries the CA, and one store holds it on every platform
An APK built in this VM pins this VM's CA, so it can never reach the
host's ai-server -- which is exactly the iris Android client's situation
(cross-compiled here, run against the host). So ai-server now puts the CA
in every enrollment link it mints, base64url of its DER under the 'ca'
parameter wg-app-link just learned to add, and client_core parses it back
out as PEM. Nothing has to be built on the machine it talks to.

Refused rather than ignored where 'ca' does not decode: a link that named
a certificate and then pinned nothing is the one outcome nothing
downstream could notice.

EnrollmentStore moves out of desktop-app into client_core::config, since
the Android client needs the same file for the same reason and only the
directory differs by platform (AGENTS.md's sharing rule). desktop-app's
--ca becomes the override for a link that carried none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:34:12 -04:00
irisandClaude Fable 5.1 9b27e858b5 docs/RUST.md: APK runtime logs in Dev Updater via an on-device ContentProvider (Iris, 2026-09-07); supersedes the ai-server client-log route
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:30:08 -04:00
irisandClaude Fable 5.1 7e4e26a335 iris: resolve fontique's Android monospace generic family ourselves
fontique 0.11.1's Android backend never resolves GenericFamily::Monospace
(mono=None in the startup diagnostic, RUST.md's 2026-09-07 "Platform
fonts" gap): DEFAULT_GENERIC_FAMILIES looks up "monospace" against
name_map before fonts.xml is parsed into it, and even after parsing,
AOSP's fonts.xml names it with a <family name="monospace"> element whose
<font> children the backend's own parser never reads (a TODO left in
place) -- so the name gets a FamilyId with no font data behind it, and
family_by_name("monospace") comes back empty too. Confirmed still present
on linebender/parley's main branch, so there is no newer release to bump
to.

TextData::patch_android_monospace (Android-only, called from
TextData::default) reads fonts.xml's own "monospace" declaration for the
font filename it names, then finds which of fontique's actually-scanned
families owns a font file with that name and registers it as the
Monospace generic directly -- the same authority Compose's
Typeface.MONOSPACE resolves through, without pinning an OEM-specific
family name. Verified on this checkout's emulator:
mono=Some("Droid Sans Mono") in the startup log, and a screenshot showing
the bench-fixture's code block and tool-card values in a visibly
monospaced face beside sans body/heading text. Desktop's fontconfig
backend is unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:25:27 -04:00
irisandClaude Fable 5.1 84a13e806b iris: a fling starts at Compose's velocity, which is a curve fit and not an average
Iris, from the phone on the 4274b8b build: "flinging now actually works
but is slower than Compose's immediately after releasing the flick (the
slow down seems correct)." The spline was already AOSP's; the initial
velocity was not.

`VelocityTracker` held per-frame pan deltas and answered their sum over
the sample span -- an average, which cannot tell an accelerating flick
from a steady drag. Ported from the `-sources.jar` of
androidx.compose.ui:ui-android:1.12.0 and
androidx.compose.foundation:foundation-android:1.12.0 (the versions the
Compose app builds against) rather than from memory, and the reading
corrected the plan twice:

  * The touch path is not `Strategy.Impulse`. `scrollable`/`draggable`
    release through the 2D `VelocityTracker`, which on Android is two
    `VelocityTracker1D(strategy = Lsq2)` over absolute positions -- a
    degree-2 least-squares fit differentiated at the newest sample.
    Impulse is reached only by `DifferentialVelocityTracker`, whose one
    caller is `NonTouchScrollingLogic`: wheel and trackpad.
  * There is no minimum fling velocity. `ViewConfiguration`'s 50dp/s is
    used only by `NestedScrollInteropConnection`; `DefaultFlingBehavior`
    skips `abs(v) <= 1f`, and says in its own comment that this is to
    dodge a NaN out of the spline. So `List::fling` caps at 8000dp/s
    against its own density and floors at 1px/s, and no threshold
    Compose does not have was added.

So the tracker holds positions rather than deltas (Lsq2 refuses
differential data in Compose too), 20 of them, with Compose's 100ms
horizon and 40ms stopped-gap; `DragGesture` feeds the raw window
coordinate along the drag axis at the press and every `Pan` frame.

`iris/benches/velocity_reference.py` is the independent transcription
the checked-in numbers come from, as `fling_spline_reference.py` is for
the curve. On `flick-120hz.touch`: 11750px/s before, 15250px/s after. On
an accelerating flick -- the shape a real finger makes, which that 16ms
recording is too short to show -- 1080 before, 2445 after. An average
also flings from a standstill (2533px/s where Compose says 0) and flings
from two points that describe no curve.

Negative control: reverting `velocity` to `total / span` fails exactly
seven tests, all of them about the estimator, and leaves the steady
drag, the tap, the selection release, the sixteen arbiter tests and the
rest of phone_screen.rs passing.

`iris drag release:` keeps its info line and gains a debug
`iris drag release samples:` with every held sample as `t_ms:position`,
so a flick that felt wrong on a phone with no logcat can be replayed at
layer 1 or pasted into the reference script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:24:29 -04:00
irisandClaude Fable 5.1 452c44249f docs/RUST.md: queue -- logging landed; iris app enrolment replaces the build-time log destination; build-apk.sh traps
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:22:29 -04:00
irisandClaude Fable 5.1 238057ad5e docs: the phone-logging decision, how to use it, and two build-apk traps
DECISIONS.md gets the route and both rejected alternatives with what each
would have cost; RUST.md gets a "Phone logging" section with the build
command, where to read it on the phone, the end-to-end verification, and
the two rig traps that cost an hour -- Gradle's merged-native-libs cache
surviving build-apk.sh's `rm -rf jniLibs` (a --abi x86_64 APK packaged
arm64 and aborted with what reads exactly like a Vulkan fault), and the
648 MB debug bench APK that cannot be installed at all. IRIS.md gets the
client-core logging API with a before/after.

Queue item ticked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:21:23 -04:00
iris 896c93a59a iris: drop bundled Noto Sans, match Compose's platform-font fonts
Iris's call: "remove the font for now; just match what compose does."
Removes the six embedded Noto Sans/Noto Sans Mono TTFs (3.6 MB) that
TextData::default used to register ahead of the platform's own fonts;
fontique's system font discovery was already on by default and now
runs unshadowed (Roboto/Roboto Flex on Android, fontconfig on the
desktop). .so -3,748,136 bytes (11,193,608 -> 7,445,472), matching the
estimate. Verified fallback still lands on visible tofu for CJK/emoji
rather than blank, and flagged (not fixed) a fontique Android backend
gap that leaves Monospace unresolved -- see RUST.md's "Platform fonts
(2026-09-07)" and DECISIONS.md/IRIS.md's dated entries.
2026-09-07 16:14:34 -04:00
iris 690161e5e9 docs: the transcript's edges were three faults, and what the rig found
IRIS_TODO's 2026-09-07 top-edge entry closed with the root cause of
each, the six layer-1 test names, and what was suspected and turned out
not to be it -- no culling test compared a row's top against the
viewport's, and 03c6be8's header duplicate is untouched and still open.
The later report's "you shouldn't be able to scroll below the bottom (or
above top)" is ticked with why the clamp is a correction measured from
the layout walk rather than a clamp inside the scroll setter: nothing at
the moment of a scroll knows where the content ends.

RUST.md gains the same account in "Where things stand", plus the three
things this said about the new test rig -- layer 1 found all of it in
seconds and the emulator was not used; layer 2 is where the missing clip
is visible, with the command; and an assertion that reads the wrong
thing hides the bug it is for, which is how a list resting 1398px past
its own first row passed a test about stopping at that row.

Also the last of the six tests, the bottom end of the clamp
(`scrolling_past_the_last_row_settles_on_it`) -- the same rule at the
edge the top-edge work had no reason to touch.
2026-09-07 16:07:37 -04:00
iris e922b73d7a iris: a transcript row is drawn if it overlaps the viewport, and clipped to it
Iris's phone, 2026-09-07, two screenshots of the transcript at its top
edge wrong in opposite directions: rows already scrolled past still
drawn, over the header bar (`version = "0.1.0"` behind "Run benchmark"),
and a blank band where the row straddling the edge should be. Three
faults, one rule -- `List::intersects_viewport`: a row is drawn if any
part of it is inside the list's own box, and nothing outside that box
reaches the screen.

1. **The walk drew everything between the anchor and the viewport.**
   `scroll` moves the anchor's offset and nothing else, so panning leaves
   the anchor's own row further and further outside the viewport, and
   every row in between was placed *and drawn* on every frame. Measured
   on the bench fixture: 8 scrolls of 3000px left 64 rows drawn for a
   2012px viewport, ~59 of them off screen. `place` now skips a row whose
   height is already known and whose box does not overlap; `rehome_anchor`
   moves the anchor onto a visible row each frame, without moving
   anything drawn, so the walk is O(visible) again whatever distance was
   travelled. `extents` holds only what is on screen, which is what
   `key_at` already claimed of it, asserted at the end of every draw.

2. **Nothing clipped the list.** A straddling row is drawn in full --
   that is the rule -- so the part above the list was on screen. The
   transcript's list is `.masked()` now (the mechanism `examples/
   message_list.rs` and the composer already use, and one that nests as
   of the previous commit), and `List::draw` asserts it has a mask rather
   than leaving that to each caller to remember.

3. **A fling past the first row stayed past it.** `tick_fling` stops a
   fling that has reached an end, wherever the spline's last step had put
   it: `fling_toward_the_start_stops_at_the_first_row` was leaving the
   first row 1398px below a 600px viewport -- a blank screen -- and its
   assertion could not see it, since `extents` then held off-screen rows
   too and `top >= -0.5` is satisfied by +1398. `clamp_to_content` gives
   the gap back from the ends the walk already placed. Only when the
   opposite end is not also in the viewport, so a list shorter than its
   viewport stays bottom-anchored as before.

Layer 1 of the test rig throughout (`transcript-fixture/tests/
top_edge.rs`, the real screen under a bench-app-shaped header): each of
the five fails on its own subject and no other -- culling on the row's
top instead of its bottom fails only `the_row_across_the_top_edge_is_
drawn`, the pre-fix walk fails only the two about what is placed,
dropping `.masked()` fails only `the_list_is_clipped_to_its_own_box`,
dropping the clamp fails only `scrolling_past_the_first_row_settles_on_
it`. The bottom edge and a list shorter than the viewport are the ends
none of this had a reason to touch and are covered too.
2026-09-07 16:05:31 -04:00
iris d507ae4c96 iris-core: masks nest instead of aborting, and a widget can ask to be drawn again
`Painter::set_mask` refused a widget any mask of its own once an
ancestor had set one -- `assertion failed: self.mask == MaskIdx::NONE`
-- so clipping was one level deep wherever it was used at all. That is
what stopped the transcript's `List` from being clipped to its own box:
its rows already use `.masked()` themselves (a code fence, a tool card's
one-line title), and giving the list one aborted on the first fence
drawn.

A mask now carries the mask it was set inside (`Mask::parent`) and the
fragment stage walks that chain, so a pixel has to be inside every mask
on it. Chained rather than intersected on the CPU because each mask
moves with its own widget: a fence inside a transcript row carries the
row's scroll and the list's box does not, and one region resolved when
the fence was last drawn gets the second of those wrong as soon as the
row is moved rather than redrawn -- which is every scroll frame. The
child holds one ref on its parent's slot, released where the child's own
slot is, so a chain cannot outlive what it points at. The old assert
survives as the case that is still wrong: the same widget setting two
masks, which since a mask now chains would be a clip loop.

Also `Painter::draw_again`, for a layout that can only discover a
correction to itself by laying out once -- `List::clamp_to_content`, in
the commit after this -- and `Painter::is_masked`, which is how a widget
that draws outside its own box can require something to be clipping it.
2026-09-07 16:05:13 -04:00
irisandClaude Fable 5.1 9ed01e2812 docs: phone report 2026-09-07 later -- overscroll, low initial fling velocity, input/timing report; queued
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:04:34 -04:00
irisandClaude Fable 5.1 5be9f1baac iris-android-app: keep the app's own log, put it in Copy report, upload it
`app_log` is the platform half: `android_logger` as the logger the ring
forwards to, and an optional destination baked in by `build.rs` from
`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA -- the same
build-time trust boundary the transcript config and the Compose APK's CA
already use, so no token is committed and an APK is good for the server
that built it. All three or none: two of the three would be a build with
nowhere to send its log and no way to say so.

`Copy report` now appends the ring to what goes on the clipboard (not to
the pane, which is on screen and would be buried) and flushes the
uploader first, so the lines are on the server by the time the message
describing them arrives. The Diagnostics pane gains two lines: how many
lines are held and when the last arrived, and what the uploader last did
-- "not tried yet", "failing -- <why>", and "no server configured" are
each their own wording, because "nothing is arriving" has three causes
that look identical otherwise.

Also: the re-emitted lines carry the target `ai_server::client_log`, not
a bare `client_log`. `RUST_LOG=ai_server=debug` -- the filter AGENTS.md
tells people to run with -- drops a bare target, so every line a phone
sent vanished with nothing saying so. Found by running it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:01:30 -04:00
irisandClaude Fable 5.1 977bdb9ee0 client-core: the app's own log ring, and POST /client-log to get it off a phone
Iris tests iris builds on a phone with no adb, and Android forbids one
app reading another's logcat, so a `log::info!` in the app can only reach
her if the app carries its own copy and sends it somewhere.

`client_core::log_ring` is that copy: a bounded ring (2000 lines / 256
KiB, whichever bites first) behind a `log::Log` backend that forwards to
whichever real logger the platform installed, so `logcat` and the desktop
terminal see exactly what they saw before. Reading does not consume --
the report and the uploader are two readers of one ring.

`client_core::log_upload` drains it into ai-server's new `POST
/client-log`, which re-emits each line into the server's own tracing
output. Dev Updater already shows that as ai-server's runtime log, so
nothing new is built there. A failed batch is retried from the same
cursor, and nothing in the upload path calls `log!` -- it would land in
the ring it is draining.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:56:20 -04:00
irisandClaude Fable 5.1 9cd1263080 docs/RUST.md: queue -- APK size done, the embedded-fonts question left for Iris
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:47:53 -04:00
iris 42af780639 iris android-app: strip+LTO+cgu1+opt-level=s halve libmain.so, no feature trim needed
Baseline had panic=abort only. Measured each setting in order (docs/RUST.md's
new "APK size (2026-09-07)" subsection has the full table and crate
breakdown): strip=true, lto="fat", codegen-units=1, opt-level="s" take
libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the release APK
from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a. opt-level="z" was
measured (another ~800KB) but not adopted without a frame-time check.

Investigated naga/wgpu backend features and tabs-ui/tabs-screen as trim
candidates; both are already fully eliminated by the linker on Android
(0 symbols in `llvm-nm` on the baseline .so), so no Cargo feature change
would shrink the binary -- left as documented findings rather than a diff.

Embedded Noto Sans fonts (3.6 MB) and the wgpu/naga/font-shaping stack
account for most of what remains vs. Compose, which borrows the platform's
own renderer and fonts for free; recorded honestly in the doc rather than
trimmed, since subsetting fonts or dropping a backend would change what
iris can render.
2026-09-07 15:46:40 -04:00
iris 4274b8b8d0 Merge remote-tracking branch 'origin/rustify' into worktree-agent-ace98b0bdaf33ffff
# Conflicts:
#	docs/IRIS.md
#	docs/RUST.md
2026-09-07 15:33:25 -04:00
irisandClaude Fable 5.1 73f956f8e0 iris: the fling curve was the identity function, and the keyboard was a targetSdk
Iris's 2026-09-07 phone report on ed04d4c: the resume glyph corruption is
fixed (item 4 closed with her evidence), flinging "seems to just be linear
velocity with an abrupt stop", and the keyboard still does not push
anything up. docs/RUST.md's new "The 2026-09-07 phone report" section has
the derivation and every number.

**The fling was arithmetically linear.** `android_fling_spline::
distance_fraction(t)` returned `t` for every `t`. Two halves of AOSP's
`SplineOverScroller` static initialiser had been transposed -- the
bisection solved the tension curve and the sample evaluated the P1/P2 one,
where AOSP does the opposite -- which made SPLINE_POSITION and SPLINE_TIME
identical; the lookup then bracketed `t` between SPLINE_TIME entries
instead of between even time steps, and the two cancelled to the identity.
Ported exactly now from OverScroller.java and androidx.compose.animation
1.12.0's SplineBasedDecay.kt, which agree line for line, as one table
indexed by even steps of time (AOSP's second table serves only
`adjustDuration`, which nothing here has, so it is deliberately not built
-- one array, one indexing rule). `FlingCalculator::velocity_at` is new
beside `position_at`, and `List::tick_fling` logs `iris fling tick:` with
the per-frame delta and speed.

Every existing test compared the calculator with itself -- monotonic,
signed, integrates to the closed form, deltas non-increasing -- and all of
them pass on a straight line. iris/benches/fling_spline_reference.py is an
independent hand transcription of both sources and supplies the numbers
now checked into `the_spline_matches_aosps_own_table` and
`a_flick_decelerates_the_way_aosp_says_it_does`;
`tick_fling_applies_shrinking_incremental_deltas` went from
"non-increasing" to "the last delta is under 80% of the first". Negative
control: with `sample` forced back to `t`, exactly those three fail.

Emulator (API 36, debug, force-gles): a released v=3750 decelerates
3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s over 32 frames
to t=0.664s; a flick into the end of the list stops there in one tick with
no overshoot; a tap 200ms into a fling ends it at 11 ticks.

**The keyboard: `targetSdk = 34`** in iris/android-app/app/build.gradle,
against compileSdk 37 and the Compose app's 37 -- and that app's keyboard
does push up on her phone. Below target 35 a window keeps the legacy
behaviour where adjustResize shrinks it for the IME, so
getInsets(ime()).bottom measures an already-shrunk window and is zero;
setDecorFitsSystemWindows(false) opts out of that and still takes on the
API 36 emulator here, which is why every test run passed. Now targetSdk 37.

That is a reading and not a measurement, so the other half is making the
phone able to answer it. MainActivity also registers a
WindowInsetsAnimation.Callback (onEnd re-reads getRootWindowInsets, so an
interrupted animation cannot freeze a value), which delivers the height
where only the animation path carries it and makes the push-up animate:
ime_bottom now arrives 509, 663, 833, 881, 883 instead of one jump.
`insets::Shared::updates` counts every dispatch and
`AndroidUiState::insets_report()` puts it in the Diagnostics pane --
screenshot-verified, `insets: dispatches=27 left=0 top=142 right=0
bottom=63 ime_bottom=0 ime_visible=false`. Iris has no logcat, and "the
listener never fired" and "it fired with a zero height" are otherwise the
same picture; dispatches=0 says so in words rather than showing defaults.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:44:01 -04:00
irisandClaude Fable 5.1 038f6a3832 docs: the test rig's layers 1 and 2, with their commands and their limits
RUST.md's "Three test layers" section rewritten in place with what was
built: the `cargo test -p transcript-fixture` command and the five
assertions with the mutation that fails each, the `run-headless.sh
--phone [--replay …]` commands and the 15s/18s they take, and a
paragraph on what still cannot be answered below layer 3 (anything about
pixels, any frame time, anything JNI). Also the two traps that cost time
-- `swaymsg seat - cursor` reaching nothing on a compositor with no
input devices, and a leftover window tiling beside the new one so a
screenshot looks like a duplicated-primitive bug.

IRIS.md gains the public surface: `iris::harness`, `TouchScript`,
`List::fling_velocity`, the fling's clock, and the desktop backend's
move to physical-pixel layout with `content_scale`/`IRIS_SCALE`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:53 -04:00
irisandClaude Fable 5.1 1121d7cc83 docs/LAYOUT.md: masks reference a drawn primitive instead of copying a shape, and hit-testing applies the shape (Iris, 2026-09-07)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:38:55 -04:00
irisandClaude Fable 5.1 232de0ec53 iris: a phone-shaped desktop window, driven by the same touch recordings
Layer 2 of docs/RUST.md's "Three test layers":

    ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture

opens `transcript-fixture`'s screen -- the same fixture and the same
fold the headless tests and the Android bench use -- in a window at the
phone's own 1080x2424 and `content_scale` 2.55, and screenshots it. 15
seconds, warm. `--replay FILE` drives one of the `.touch` recordings
into it and writes `<shot>-before.png` too, so "the list moved" is two
pictures: the flick carries it back about seven turns of the fixture.

Two things this needed.

**The desktop backend now lays out in physical pixels with a density,
exactly as Android does** (`default::content_scale`, overridable with
`IRIS_SCALE`, which is how `--phone` hands it the phone's). It used to
divide winit's coordinates into a separate "logical" space, which left
`UiRenderState::resize` (physical, from `WindowEvent::Resized`) and the
window uniform (logical) disagreeing on any display whose scale factor
is not 1.0, and rasterised glyphs at one resolution to show them at
another. At 1.0 -- every display here -- the numbers are unchanged, and
the `tabs` screenshot is identical.

**`rig-input`'s `replay-touch`** puts a gesture on screen. This
machine's compositor has no pointer to move: sway runs on the headless
backend with no input devices, so `swaymsg seat - cursor press` reports
success and `swaymsg -t get_seats` shows `capabilities: 0`. wlroots 0.19
dropped `WLR_HEADLESS_INPUTS` and ydotool's uinput device would be
ignored by a compositor not reading libinput, so the virtual-pointer
protocol is what is left. It parses the *same* `TouchScript` the
harness does, so one recording drives both layers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:38:19 -04:00
irisandClaude Fable 5.1 e430880cde docs: phone report 2026-09-07, rows at the transcript's top edge culled early or drawn through the header
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:35:20 -04:00
irisandClaude Fable 5.1 a999bd106a docs: masks with a shape (LAYOUT.md, decided 2026-09-07) and the orchestrator queue in RUST.md
Iris: masks should carry a shape, rounded rectangle first, or take a
container widget as the mask, with corner alpha multiplied rather than
cut. Design: the mask evaluates the same SDF draw_rounded_rect uses,
nested masks chain and multiply like moves, and a rounded Rect's
.masked() makes the container the mask with one radius by construction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:34:19 -04:00
irisandClaude Fable 5.1 6840edf61e iris-android-app: the bench's fixture half comes from transcript-fixture
The fixture bytes, the backlog/tail split and the fold into a screen
were `bench_client.rs`'s alone; they are `transcript-fixture`'s now, so
the Android bench, the headless harness and the phone-shaped desktop
window open one screen from one copy (AGENTS.md: nothing UI-shaped in a
platform crate). What stays here is the JNI half -- clipboard, battery,
IME, the report and the four phases.

Built with `cargo ndk -t arm64-v8a -P 29 build --features
"transcript-screen bench"`; the two warnings it prints (bench_jni's
unused overlay methods, the unused `tabs-ui` dependency under this
feature set) predate this change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:27:04 -04:00
irisandClaude Fable 5.1 333220196e iris: a headless in-process harness, and the bench fixture as a shared crate
Layer 1 of docs/RUST.md's "Three test layers": `iris::harness` opens a
real screen with no window, no compositor and no GPU, on an explicit
clock and a replayed touch stream -- a trivial `t_ms action x y` file,
so the batched 120Hz flick shape from Iris's phone report is
reproducible as a test. The emulator cannot produce that shape at all:
a `ui-trace` swipe is many evenly-spaced events, a finger is five
samples in 20ms.

`transcript-fixture` is the fixture-loading and fold-driving half of
`iris-android-app`'s `bench_client.rs`, moved out of the platform crate
so the harness, a desktop window and the Android bench open the same
screen from the same bytes (AGENTS.md's sharing rule).

Two supporting changes in iris itself, both about reading a clock that
was not handed in: `Fling::started_at` is now set on the first
`tick_fling` rather than at the release, so a driver running frames on
its own clock does not start every fling at the wall clock and advance
it on a different one; and `List::fling_velocity` exposes what the
release measured, which is where `Released(Some(v))` lands.

Four tests, each confirmed to fail without its subject: dropping
`animate(id)` from `Selection::drag` (the phone's own "fling does
nothing" defect) and reverting `started_at` each fail the flick test
alone; flinging on `Tapped` fails only the tap test; a 5s `LONG_PRESS`
fails only the selection test; a `set_bottom_inset` that ignores its
argument fails only the composer/IME test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:24:54 -04:00
irisandClaude Fable 5.1 7f4ea7e8fd docs/TODO.md: Compose app crash from Iris's phone log export, reversed AnnotatedString range in ToolInput.highlighted
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:22:47 -04:00
irisandClaude Fable 5.1 591128eef1 AGENTS.md: the phone app and the planned desktop app share widgets and styling; only screen layout differs
Iris, 2026-09-07. The second central design point beside the driver
rule, so a platform crate growing a widget or a colour reads as a
defect to move. docs/RUST.md carries the detail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:13:54 -04:00
irisandClaude Fable 5.1 ba0f2ea93f docs: the 22:16 report reconciled with what was actually run
RUST.md's "Shell lost" section and IRIS_TODO.md's matching paragraph both
said item 4's fix was written but never built or tested. It was committed
in ba2afba with its test passing, so both were stale the moment that
landed and read as if nothing had been run at all.

Replaced with one section per item, saying what was fixed, what was
measured on this checkout's emulator and what the phone still has to
settle: items 2 and 3 ticked with their numbers, item 4 ticked on the code
with phone confirmation still owed (no Vulkan adapter here), item 1 left
open with the exact logcat line for Iris to look at. The two pre-existing
faults found on the way -- the 16-deep move chain and the API-29 JNI calls
-- are recorded where the next reader will hit them.

IRIS.md gains the public-surface entry: `Widget::tick`,
`UiData::animate`/`tick_animations`, `FlingCalculator`'s density and
coefficient, and `MOVE_CHAIN_LIMIT`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:12:03 -04:00
irisandClaude Fable 5.1 ed04d4c735 iris: the keyboard reopens, the IME's height reaches the layout, and a fling actually moves
Items 1-3 of Iris's 22:16 phone report, plus the two defects that were
hiding behind item 1 and only became visible once the first one was
fixed. Emulator evidence and the numbers are in docs/RUST.md.

**Keyboard reopen.** `attr.rs`'s already-focused branch calls
`focus_gained` on a tap that stays inside `DRAG_SLOP` -- what Android's
own `EditText` does, `showSoftInput` being idempotent. Dismissing the IME
leaves the field focused, so the only branch that requested it never ran
again. Negative control run: without this one call the second tap leaves
`mInputShown=false`. Swipes across and out of the focused field still
summon nothing.

**IME height.** `MainActivity` sends `getInsets(ime()).bottom` and
`isVisible(ime())` as two values; the height used to be sent *as* the
boolean, so nothing had a number to pad by. `Insets`/`WindowInsets` carry
both, `bench_client` reads the boolean for its state machine and the
height for `Composer::set_bottom_inset`, and the list follows because it
is `rest(1)` in the same `Span`.

**Fling.** Three defects, in the order they were found:

1. `on_touch_event` read only each `MotionEvent`'s final position, so a
   batched 120Hz flick fed the tracker one sample and `velocity()`
   answered 0.0. Historical samples are replayed through the sensor pass
   now, `CursorState::time` carries each sample's own time (so a replay
   loop's speed cannot become the measured velocity -- the winit backend
   sets it too), the press is a sample as AOSP's own tracker does, and
   `iris drag release:` logs the decision for the phone's logcat.
2. Nothing advanced a fling between input events: `tick_fling`'s only
   caller was the benchmark's own loop, so the bench flung and a finger
   never did. iris has one animation mechanism now -- `Widget::tick`,
   `UiData::animate`/`tick_animations`, called by both backends before
   the draw and re-requesting a frame while it answers true.
3. With flings finally animating, one lasted 45 seconds: `List::fling`
   hardcoded density 1.0 against physical-pixel velocities, and
   `FlingCalculator`'s coefficient used the scroll friction where AOSP
   uses its 0.84 tuning constant -- 56x, inside an exponential. Emulator:
   1.62s for v=11064, against AOSP's own 1.586s.

**Two pre-existing faults found on the way.** `MOVE_CHAIN_LIMIT` was 16
and the composer's chain is 17, so every debug build aborted on a tap of
the composer and every release build silently drew and hit-tested that
subtree short; it is 64 in both the CPU walk and shader.wgsl, and the
assert prints the chain so a cycle and a deep tree can be told apart. And
`minSdk` is 29, since `getEventTimeNanos` is API 29 and a missing JNI
method is a crash rather than a degraded fling.

Every new invariant carries its guard: sample times non-decreasing in
`on_touch_event`, and tests confirmed to fail without their fix for the
press-seeded velocity, the animation registration and the AOSP
magnitudes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:11:55 -04:00
irisandClaude Fable 5.1 ba2afbaedb iris: a cleared glyph atlas must un-cache every RenderedText, not just empty itself
Iris's phone, 2026-09-06 22:16: after leaving the app and returning,
every glyph drawn *before* the resume came back as fragments of other
letters, while the diagnostics text drawn after it was perfect.

The renderer rebuild does force a full redraw -- `surface_changed` calls
`render.resize(...)`, which sets `UiRenderState::resized`, which makes
the next `update` take `redraw_all`. What survives that is one cache
further in: `TextView::render` returns its cached `RenderedText`
whenever the wrap width, buffer and attrs are unchanged, so
`TextData::place` is never reached, nothing is re-rasterised into the
fresh atlas, and the *previous* atlas's uv_min/uv_max/layer go straight
back to the GPU. Only text whose content changed after the resume
re-shapes -- exactly the split in the screenshot.

One mechanism rather than a per-holder invalidation path: `GlyphAtlas`
carries a `generation`, bumped by `clear`; a `RenderedText` records the
one it was placed against; and `TextView::render`'s cache key includes
it, so clearing the atlas makes every cached render un-reusable at once.
`Painter::glyphs` debug-asserts that a submitted quad's generation is
the live one, catching the fault at the submission instead of on screen.

Test `clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`
(iris/src/widget/text/mod.rs): draw, clear the atlas, resize, draw
again, and assert the atlas holds the same glyph count. Confirmed to
fail without the cache-key line -- it trips the new debug_assert with
"glyphs placed against atlas generation 0 submitted against 1".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:22:40 -04:00
iris 10267dec27 Merge branch 'worktree-agent-a673ba12761c025d9' into rustify 2026-09-06 23:20:30 -04:00
irisandClaude Fable 5.1 7e7cbb5402 Tool-call cards and grouping, with the state a result never arrived in
P1b (docs/RUST.md). `transcript-ui/src/tool.rs` draws a card per tool
call and a group per run: collapsed, a card is its name and the one-line
summary `parse_tool_input` derives; open, it is the description, the
input (highlighted, on the verbatim surface) and the output, capped with
a "Show all N lines". A run is one surface with a heading and a chevron
bar at its foot, so it closes from either end.

Three things worth knowing.

**A collapsed card lays out its summary line and nothing else.** The
fixture's tool outputs are tens of kilobytes and a collapsed card never
builds a widget for one -- `collapsed_cards_shape_only_their_summary_
lines` opens a three-card group over 88 kB of output each and asserts the
text-shape count equals the same group's over three bytes (17 either
way; 17 against 20 when the discipline is deliberately broken, so the
test is real).

**A result arriving replaces one card.** `ToolRow::apply_calls` is the
group's half of `RowBlocks::apply_delta`'s rule, and `build_row` now
hands back one `TailRow` -- blocks for a message, cards for a run --
rather than two mechanisms chosen at each call site.

**Every tap is a tap**: `GestureOutcome::Tapped` out of the `DragArbiter`
`Selection` already owns, so a drag that started on a card scrolls the
transcript instead of opening it.

Three defects found by looking at the render, all recorded with their
repro in docs/IRIS_TODO.md: a `Span` of padded children inside another
`Span` places them a slot out of step (worked around by building the
group as one span, which costs the 4dp inset); `scrollable_on(Axis::X)`
on a non-editable text draws nothing, so a card's command is clipped
rather than pannable; and `NotoSans-Regular` has no U+25B8/25BE/25B4 at
all, so the expander mark is set in the monospace face.

Screenshots: docs/bench/p1b-2026-09-06/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:49:51 -04:00
iris a200ddbddd docs/IRIS_TODO.md: Iris's 22:16 phone report on the 20303e0 build, four open items with the reading of each 2026-09-06 22:31:07 -04:00
iris b332873894 Merge remote-tracking branch 'origin/rustify' into worktree-agent-a673ba12761c025d9 2026-09-06 21:33:28 -04:00
irisandClaude Fable 5.1 a4809b3026 WIP: tool-call cards and grouping (P1b)
`transcript-ui::tool` draws a card per call and a group per run, with
the states, the collapsed-lays-out-nothing discipline and the
one-card-per-result update. Screenshots in docs/bench/p1b-2026-09-06/.

Includes a local fix to `List::place`'s reposition-vs-mov clash, which
is about to be dropped for rustify's own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:33:24 -04:00
iris 1ad2f9ec6e docs/RUST.md: phone delivery is a push to ai-app-bench, not ~/host/bench 2026-09-06 20:04:26 -04:00
iris 33e8ab83a2 docs/RUST.md: the two 2026-09-06 fixes under P1a, with the emulator's first legible screenshot 2026-09-06 19:59:57 -04:00
iris f5b88932b4 iris: a widget's move slot has one owner -- move_applied + repositioned
`mov` accumulates a delta onto the slot and `reposition` overwrote it, and
both legitimately land on one widget in one frame: `List::place`'s
Bottom-known branch offers a row a same-size box that has moved (`mov`),
then corrects the placement inside it when the row's cached height no
longer matches what the row reports (`reposition`). That is what a wrapped
transcript row hit, and what the `move_applied == ZERO` debug assert was
standing in for -- an assert against a case that happens is not a
guarantee, it is a crash.

The slot means `move_applied + repositioned` now, both halves recorded on
`ActiveData`, so `reposition` adds the move rather than dropping it and
stays idempotent. The assert it replaces is a `debug_assert_eq!` that the
slot still holds that sum on entry -- i.e. that nothing but those two ever
wrote it.

Test: `a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`,
which draws the child at the offered position (-100px) rather than the
placement (100px) without the fix. Verified against the `.wrap(true)`
repro from docs/IRIS_TODO.md (draws correctly, no panic) and an emulator
bench run with assertions live.
2026-09-06 19:59:39 -04:00
irisandClaude Fable 5.1 9079276ec8 A tool call can say it failed, and what it is for, without a renderer
P1b's pure half (docs/RUST.md). Three pieces, all testable with no
widget in sight:

- `event_model::Event::ToolEnd` gains `is_error`, read from the CLI's own
  `tool_result` field by both the live translator and the import replay
  (`import::tool_result_is_error`, one reader so the two cannot disagree
  about the same conversation). Without it a result is all a card has,
  and a broken call draws exactly as confidently as one that worked --
  the missing state, not a wrong one. `#[serde(default)]`, so an older
  transcript reads back as "not reported to have failed".
- `client_core::transcript_fold::ToolState`: Running, Deciding,
  Succeeded, Failed, NoResult. The pair it exists for is the last two
  against Succeeded-with-empty-output -- a call that printed nothing and
  a call whose result never arrived leave the same empty string, and only
  the session's status separates "still going" from "nobody found out".
- `client_core::tool_summary::parse_tool_input` and
  `client_core::durations`: `ToolInput.kt`'s subject/description/timeout
  split and `Durations.kt`'s span formatting, ported with their tests.

The echo driver's three-call run now has a failing middle call, so the
failed appearance is reachable from `ui-sandbox.sh` at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:46:21 -04:00
iris 3cb18ac5c2 iris: a one-layer glyph atlas is a GL_TEXTURE_2D, so every glyph drew as a box
The emulator was blamed for two days for what is iris's own defect on any
GL adapter. `GpuTextures::new` created the atlas `texture_2d_array` with
one layer; wgpu-hal picks the GL target from the descriptor alone
(`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`), so the
shader's `sampler2DArray` was handed a `GL_TEXTURE_2D`, the unit was
incomplete, every `textureSample` returned (0,0,0,1), and `draw_glyph`'s
`color.a *= texel.a` painted the whole glyph quad.

`MIN_ARRAY_LAYERS = 2`, with the account at `create_array_texture` and a
`debug_assert!` there. Vulkan -- the phone's build and the desktop's
default backend -- was never affected.

`force-gles` now switches the desktop backend too, so the GLES path is
reproducible on a machine with a real GPU in seconds rather than only
through an APK: that is how this was found, with two shader probes
showing the sample was exactly (0,0,0,1).
2026-09-06 19:41:40 -04:00
irisandClaude Fable 5.1 69525bd131 iris: a Rect is not size-independent, and P1a's block appearance verified
The defect P1a's screenshots found, and the one that mattered:
`Rect::is_size_independent()` answered `true`. A `Rect` fills whatever
region it is handed, so its content *is* the region -- and
`draw_inner`'s fast path, which rewrites a widget's primitives with
`r.outside(&from).within(&region)` instead of redrawing it, cannot
reproduce that once a region carries both `rel` and `abs`. What it
looked like: a fenced code block's background kept the height of the
provisional full-region draw `Span` does in its first phase, so one
fence's panel covered every block below it and every row below that,
with the text underneath laid out correctly. Likely the same cause as
RUST.md's older "the composer bar's grey background is not drawn".

Also here: a quote's bar is a `Stack` background behind padded text
rather than a two-child `Span(Dir::RIGHT)` (one widget fewer and no
provisional pass), and `transcript-ui`'s `transcript` example gains a
row holding one of every block kind -- the fixture's own heading,
paragraph, fence and table source, plus a list and a quote, which the
fixture has neither of.

docs/bench/p1a-2026-09-06/ has the pairs and docs/RUST.md's P1a box
names what still differs. The iris half is from the desktop backend
because this emulator cannot draw iris's glyphs at all (solid boxes,
reproduced on the previous commit, with Compose drawing text correctly
on the same AVD); both routes to Vulkan on this AVD were tried and both
fail. Bench stream phase, assertions live, no abort: p50 53.0ms p90
108.6ms p99 132.0ms against 52.8/108.1/137.3 before -- unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:30:39 -04:00
irisandClaude Fable 5.1 64f64b54e5 iris: per-block markdown appearance, syntax-highlighted fences, tappable links
P1a (docs/RUST.md). A transcript row's blocks are drawn the way
Markdown.kt draws them rather than as one flat span list:

- transcript-ui/src/markdown.rs is a *block* renderer now.
  `BlockFrame` is the whole widget vocabulary -- Plain, Verbatim (a
  dark rounded panel that pans sideways) and Quote (a bar and an
  indent) -- so a new markdown feature costs spans, not widgets.
  `frame_of` is the one place the BlockKind -> appearance mapping is
  written.
- Fences take `client_core::highlight`'s spans by language, in the
  same Catppuccin palette Theme.kt's `catppuccinSyntax()` uses, with
  the char->byte offset conversion the two index spaces need.
- Lists get the bullet ladder and coloured markers MarkdownPieces.kt
  draws, ordered lists count from the number they were written with,
  headings take Material's own ladder (24/22/16/14/12/11).
- Tables are padded monospace columns measured from the cells, with
  the header bold and a rule under it -- see docs/DECISIONS.md for
  what that trades against a real grid.
- Links carry their URL through to a tap. `GestureOutcome::Tapped`
  is new: a press that never committed to a pan or a selection, so a
  finger that flung the list past a link does not also open it.
  `iris::platform::OpenUrl` is the capability, implemented by each
  backend (xdg-open/open/start on the desktop, an ACTION_VIEW intent
  deferred to `after_input` on Android, the same shape
  `pending_show_keyboard` uses).
- `DragArbiter`/`DragGesture` take an axis, so a code fence pans
  across its own long lines through the same machine a list pans
  down its rows -- and a vertical drag starting on a fence still
  reaches the list.
- `TextEditCtx::byte_at` answers which byte a tap landed on without
  exposing the parley layout; `Rect::radius` takes a `Len`, so a
  corner can be written in dp.

Tests: 31 in transcript-ui (11 new, covering the frame mapping,
highlighting including a multibyte fence and an unknown language,
list markers, table padding and wrapping, link hit-testing), 85 in
iris (4 new on the tap-vs-drag rule and the two axes).
cargo fmt clean, clippy warning-free.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:55:46 -04:00
irisandClaude Fable 5.1 20303e0b4c IRIS.md: take_counters gained a fourth number, text shapes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:40:38 -04:00
irisandClaude Fable 5.1 6973a89815 docs: the verification pass over Tasks A and B, and the composer background withdrawn
RUST.md gains the pass's findings with their commits and the numbers:
the block model held under a per-character prefix property, the
size-independent hit-box defect and its fix, the tail-rebuild selection
gap, why the three new debug_asserts are whole-set, the text-shape
counter that turns "a delta costs one block" into a measurement, and the
verification bench run.

IRIS_TODO.md's "the bar's own grey background is not drawn" is
withdrawn: decoding the screencap puts it at rgb(41,40,49), full width,
y2245..y2365 -- drawn, and dark on black, which is most likely what the
earlier reading was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:40:25 -04:00
irisandClaude Fable 5.1 c3cfc67bb3 iris: count text layouts, so "a delta shapes one block" is measured rather than argued
take_counters gains a fourth counter, text shapes, bumped in
Painter::render_text -- which TextView::render only reaches on a cache
miss, so it counts shapes and not requests. A draw counter cannot stand
in for it in either direction: a widget can be redrawn without
re-shaping (the layout is memoized by width) and re-shaped without any
extra draw, and re-shaping is the whole thing the per-block transcript
row exists to avoid.

With it, a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one
asserts the number docs/DECISIONS.md's 2026-09-06 entry actually claims:
one delta into a 100-paragraph reply shapes exactly one text layout, the
same as into a one-paragraph one. Before the split that was necessarily
O(message), since the reply was one buffer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:32:31 -04:00
irisandClaude Fable 5.1 155d899e55 transcript-ui: pin the tail rebuild's unregister with the case that broke it
e1030d6 made Selection's key (RowKey, u32) and changed apply's
ReplaceLast arm to unregister unconditionally rather than only when the
key changed -- correctly, but with nothing exercising it. The case is a
tail row rebuilt under the *same* key with fewer blocks than it had: the
blocks that no longer exist keep pointing at widgets replace_back's drop
frees, and Selection::begin resolves every registered handle on an
ordinary press, so the next tap anywhere in the transcript panics. The
old `if new_key != old_key` guard could not see it, because nothing
about the key changed.

Selection::registered_blocks (test-only) is what lets the test assert the
contract unregister states -- every block of the row, not the first --
instead of only that nothing panicked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:32:05 -04:00
irisandClaude Fable 5.1 e63e923d44 iris: a size-independent widget's hit box lands where it is drawn
draw_inner's third fast path -- offered region changed shape, widget's
output does not depend on it -- rewrites the widget's own primitives in
place and writes no move-slot delta at all. 167862c added a
move_applied increment there, copied from mov, where region and the slot
delta really do move together. Here only region moves, so resolved_region
subtracted a distance the chain never held and every such widget's hit
box sat short of its drawing by exactly the last step it took.

Span reaches this on the first frame of any tree it is in: it measures
each child at the full region and then places it, which for a Rect (the
.background(rect(..)) idiom, list row tints) is a size change through this
branch. So the hit box was wrong from the start, with the drawing correct
-- nothing on screen to say so.

a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at
is the sibling of a_panned_widgets_own_hit_box_moves_exactly_once on the
branch that fix had no reason to touch; it fails on both frames without
this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:31:58 -04:00
irisandClaude Fable 5.1 a56a928b0c client-core: the transcript's own markdown shapes, and the streaming property as a property
split_blocks was tested on the shapes it was written against. These are
the ones a real reply contains -- a fence with blank lines in it, a `---`
inside a fence, a nested list, a fence directly under a heading, a table,
a quote -- plus the property RowBlocks::apply_delta actually depends on,
checked at every character boundary of a message that has all of them:
growing a message may rewrite its last block and never an earlier one, or
common_prefix must say so. No defect found; the split already held.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:28:57 -04:00
iris 0449a324ef docs/RUST.md: P1 started on Iris's word, sub-order P1a-P1e by what makes the bench fair 2026-09-06 18:28:48 -04:00
irisandClaude Fable 5.1 e1030d69f6 iris: a transcript row is a column of markdown blocks, so a streamed delta costs one block
A row was one TextEdit holding the whole message, so every delta
re-shaped every paragraph of a long reply through parley -- the one phase
where iris trails Compose on the phone (p50 18.2ms vs 13.4ms, bench v2).

- client-core/src/markdown_blocks.rs: split a message into its top-level
  blocks with their source, through the same pulldown-cmark the renderer
  parses with so the two cannot disagree about where a block starts, plus
  common_prefix. Appending markdown can rewrite an earlier block (a
  trailing --- turns the paragraph above into a heading), so the fast
  path compares the prefix it keeps rather than assuming it -- with the
  test that says so.
- transcript-ui: a row is a Span of one TextEdit per block;
  RowBlocks::apply_delta replaces the block a delta lands in;
  TranscriptScreen keeps the tail row's blocks, seeded in build_tree as
  well as push_row (a screen opened onto a streaming reply took the
  rebuild path for its first delta otherwise, with nothing to say so).
- A block is the selection unit: Selection is keyed by (RowKey, u32),
  which is reading order at both levels, and the pointer-captured half of
  a drag resolves the block under the finger from its drawn box
  (Selection::locate) instead of from the row's extent.

Pass condition: a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one
drives a real UiRenderState and asserts the draw count for a delta into a
100-paragraph (3,000+ char) reply equals the count for a one-paragraph
one. 30 either way; it read 630 against 30 twice on the way there.

Emulator stream phase, same AVD before and after: p50 61.5 -> 54.5ms,
p90 211.7 -> 113.1ms, p99 342.6 -> 137.4ms, worst 403.6 -> 143.0ms, 202
-> 293 frames in the same 21 seconds. Selection across blocks verified
with a real long-press drag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 17:33:37 -04:00
irisandClaude Fable 5.1 167862ca1b iris: the composer scrolls on a finger -- a dp cap worth zero, a stale mask slot, a hit box moved twice
Wrapping the composer's field in .scrollable().masked() needed three
layout defects fixed first, each with a headless regression test that was
confirmed to fail without its fix:

- MaxSize/Sized reported a caller's declared dp length unresolved, and
  Span places a child from the abs/rel of what it reported, so dp(168)
  was worth zero: the bar got a slot of nothing the moment its content
  passed six lines and the Scroll inside measured its container at -63px
  (container=-63 content=415.8 amt=478.8 on the emulator). Len::fold_dp,
  used on the way out, plus a debug_assert in draw_inner that a reported
  Size carries no dp -- the rule is about every widget, not those two.
- Masked allocated a fresh mask slot per draw, and draw_inner's
  unchanged-region fast path does not revisit descendants, so they kept
  clipping against a box the bar had moved away from: four live mask
  entries, none of them current, and the field drew nothing.
  ActiveData::own_mask, allocated once and rewritten in place.
- mov updates active.region and accumulates the same delta on the move
  slot, and resolved_region added both, so a panned widget's own hit box
  sat at twice the pan -- the composer's field was untappable after a
  drag. ActiveData::move_applied.

Scroll itself measured the right number by a misleading route; it is
written against painter.px_size() now and still reports its content's
size, since reporting the container makes the answer a function of
itself.

Verified on this checkout's emulator: swipe 540 1200 -> 540 1460 moved
the field's Message box 31,1041..1048,1509 -> 31,1131..1048,1651 with its
height unchanged at 468px.

run-bench.sh polled logcat for a prefix copy_report also logs at startup,
so it printed a report that had never been run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 17:17:42 -04:00
iris d73db97629 iris/android-app/build-apk.sh: clear jniLibs before building, so only the requested ABI is packaged 2026-09-06 16:47:54 -04:00
irisandClaude Fable 5.1 fb6b459c2c iris: Scroll pans on a finger drag; a vertical drag in a focused field scrolls rather than selects
IRIS_TODO.md's "the composer has no touch-drag scroll". `Scroll::drag`
takes its pan from the same `sense::DragGesture` `List` is driven by --
arbitration, DRAG_SLOP, velocity and pointer capture all stay in sense.rs
and only what a committed pan *means* is decided per caller -- and
`WidgetLike::scrollable()` registers it beside the wheel handler it already
registered, so every scroll area pans on a finger with nothing added at the
call site. No fling: `Scroll` has no per-frame tick to animate one and the
areas it wraps are at most a screenful. `Scroll::amt()` exposes the pan
position.

`attr.rs`'s `on_press` treated an already-focused field as the plain
click_or_drag case, so every Pressing frame extended a selection. It now
applies the same DRAG_SLOP rule its unfocused branch already did: a press
past the slop vertically abandons its pending selection for the rest of the
gesture, so the scroll area around the field wins it. That is Android
EditText's own behaviour and it is what lets a swipe up over the composer
scroll instead of dragging a highlight through what you typed.

Also fixed, found doing it: `ActiveData::mask` stored the mask a widget
*set* rather than the one it was drawn *under*, and `redraw` feeds that
field back in as the inherited mask -- so a targeted redraw of any `Masked`
handed it its own mask and aborted on `set_mask`'s nested-mask assert. A
real abort on the emulator, `assertion failed: self.mask == MaskIdx::NONE`.

And the per-frame orphan guard from 76b1f99 is now a count comparison
(O(active widgets)); the O(primitives) walk only runs to build the failure
message, because running it per frame made a debug build on the emulator too
slow to finish a bench run at all.

Tests: four in scroll.rs (pan past the slop, a tap inside it, a horizontal
drag, the end clamp), `a_finger_drag_over_a_scroll_area_pans_it` in
sense_tests.rs driving the whole registration/dispatch/capture path (fails
with "got 0" without the new registration), and
`redrawing_a_masked_widget_does_not_nest_its_own_mask` in layout_tests.rs
(aborts on the pre-fix code).

The composer itself is deliberately still not `.scrollable()`: `Scroll`
measures against the window rather than its own offered box, so inside the
`MaxSize` capping it at six lines it pans the field out of the bar --
measured, reverted and written down in RUST.md and DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:45:56 -04:00
irisandClaude Fable 5.1 76b1f99277 iris: a dirty widget redrawn by its ancestor never freed its old primitives
`draw_inner` read `needs_redraw` without consuming it, and used it to skip
the whole `if let Some(active)` block -- including the `remove(id, false)`
that frees a redrawn widget's previous primitives. So a widget that was
both already active and marked dirty, and was reached by an *ancestor's*
draw rather than by `redraw_updates` picking it first, drew a second full
set of primitives and then had `active.insert` overwrite the only handles
that could ever have freed the first set. Those primitives stay in the
layer's instance buffer for the life of the process, with a leaked move
slot and leaked mask refs, drawn every frame at whatever region they last
had -- and `List` sets no mask, so a row measured at `GENEROUS_PADDING`
leaves its ghost outside the list's own box.

That is the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md:
overlapping copies inside the transcript and one more below the composer.

Fixed by consuming the mark (`needs_redraw.remove`) at the top of
`draw_inner` -- this call *is* the redraw it asked for -- and freeing the
old primitives on the dirty path too.

Guarded so it cannot come back silently: `UiRenderState::orphaned_primitives`
walks every layer's live instances and names any whose owner is no longer
active or no longer holds a handle to them, and `update` `debug_assert!`s it
empty every frame (debug builds only). New regression test
`an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy` in list.rs fails on
the pre-fix code with "1 primitive(s) survived their own widget's redraw".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 13:59:53 -04:00
iris 3e72a4ef19 docs: the defect pass's findings -- RUST.md boxes, IRIS_TODO ticks, DECISIONS and IRIS entries 2026-09-06 13:47:28 -04:00
iris c02152a4f4 iris: a tap on an empty text field left no caret, so typing was silently dropped
TextEditCtx::select compared the tap against the laid-out text's own box
and cleared the selection for anything outside it. An empty field lays
out to a zero-width box, so tapping the composer granted focus and opened
the keyboard with no caret, and insert_str returns early without one --
every keystroke went nowhere and no glyph was ever emitted. Parley clamps
a point outside the layout by itself, and a press reaching select() has
already been hit-tested to the widget, so there was nothing for the
'outside' branch to mean.

insert_str now debug_asserts rather than dropping input silently, and
UiRenderState::draw_started -- a re-entrancy guard whose test was written
after its own remove(), so it could never fire, and which grew by one
entry per widget ever drawn -- is restored to what it was meant to be:
inserted around Widget::draw, removed when it returns, asserted empty at
the top of every update.
2026-09-06 13:43:56 -04:00
iris d9872989fa iris/android: the composer's launch position was the bench report pane, plus surface/insets lifecycle logging
The empty benchmark-report TextEdit held .height(rest(1)) beside
content.height(rest(2)), so it reserved a third of the window at every
launch and pushed the composer two thirds down -- Iris's 11:39 phone
report. It is sized to its content now, capped and scrollable, and sits
above the transcript rather than under the composer.

New log::info! lines for one insets change, one surface_changed, one
renderer build and one surface_destroyed, each with the glyph/atlas
counts, so a phone's adb logcat can answer the app-switch text loss the
emulator cannot reproduce.
2026-09-06 13:26:34 -04:00
iris 2fed8b34b3 Merge branch 'worktree-agent-a6e37a2335f436d08' into rustify 2026-09-06 13:17:22 -04:00
irisandClaude Fable 5.1 1f379e8384 docs/REVIEW-2026-09-06.md: fix all ten review findings; RUST.md/IRIS_TODO.md: DragGesture merge checks
Finding 1 (the real crash): Selection::clear() drops rows and anchor,
called from TranscriptScreen::apply's Rebuild arm right before
List::clear() -- push_row re-registers survivors as it rebuilds each row.
Fixes a WeakWidget outliving the row group_tool_runs regrouped away,
which panicked the next long-press anywhere. New apply_tests test builds
a real TranscriptScreen, forces the regroup, and confirms no panic.

Findings 2-5: debug_assert!s on List::place's slot, List::fling and
FlingCalculator's velocity finiteness, VelocityTracker::add_sample's
chronological order, and FrameReport::mark_phase's non-decreasing
start_index. Finding 7: bench_client.rs's battery_line guard restructured
so the empty check can't be separated from its unwraps by a future edit.
Findings 9/10: new List tests pinning tick_fling's per-tick deceleration
and replace_back's evicted-key cleanup with a different key than the
existing tests use. IRIS.md's replace_back/clear/apply entry gained the
side-table-clearing note the Docs finding asked for.

Also records this pass's DragGesture-merge verification in RUST.md (tap
stays vs swipe doesn't, a real fling keeps moving after release, keyboard
cycles confirmed via on_insets_changed) and annotates the two IRIS_TODO.md
phone-report items it targets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 13:16:16 -04:00
iris bf3479f5c4 client-core: an unasked page is not an empty one, and two guarded invariants
Review of 73251d6's port of TranscriptSource/joinPages.

`TranscriptSource::page` answered `before == 0` with an empty `Vec`, which
is the same value it answers "this conversation has no more history" with.
That is the state the Kotlin keeps apart: `loadOlderPage` returns false at
`oldestSeq == 0` *without* touching `moreHistory`, and returns false on an
empty page *by latching it*. Collapsing the two moved AGENTS.md's paging
bug one layer down rather than fixing it. `page` returns `OlderPage` now --
`Events(vec![])` is the start of the conversation, `NothingLoaded` is not
an answer about the conversation at all.

`join_pages`' `debug_assert!` on seq ordering across the boundary is not a
true invariant: a peer note carries the seq its turn began at, which can be
older than the page it arrived in, so an ordinary transcript would have
panicked a debug build there. Replaced with the one the function exists to
enforce -- no tool id surviving in both halves.

`fetch_transcript_lines` stores `RawValue`'s exact server bytes, so the
"neither source can produce a newline" comment in `SessionCache::append`
now rests on the server's serializer staying compact rather than on a
local normalization. Checked with a `debug_assert!` in `append` and
`store_page` rather than trusted.

Tests for the failure half, which the port had none of: a 500 mid-page, a
cached line this build cannot read, and the `after` bound in the case that
actually carries one (the existing test asserted only the case with no
bound). `cargo fmt`, `cargo clippy --all-targets`, `cargo test` (112) clean
in client-core; `cargo check -p desktop-app` clean.
2026-09-06 13:00:37 -04:00
iris 312455956d Merge remote-tracking branch 'origin/rustify' into worktree-agent-a6e37a2335f436d08 2026-09-06 12:39:22 -04:00
irisandClaude Fable 5.1 73251d6b8b client-core: port TranscriptSource and joinPages page-boundary healing
Closes docs/RUST.md's "client-core prerequisites for P1" box: the
cache-vs-server stitching TranscriptSource.kt does, and the
joinPages/healSplitMessage/adoptRun page-boundary healing
TranscriptItems.kt does, both ported into client-core with no UI
framework dependency.

Neither Kotlin file had a JVM unit test of its own, so the port used the
Kotlin source and AGENTS.md's "things that have bitten" paging incidents
as the spec instead of a test-for-test transcription. Both regressions
get a dedicated test: TranscriptSource::page refuses before == 0 before
touching the cache or the network (loadOlderPage's incident), and
adopt_run now runs on every page join rather than only the one where a
split call was found (the "one run drawn as two" incident).

fetch_transcript_lines (api.rs, additive) pairs each transcript line with
the exact server bytes via serde_json::value::RawValue rather than
re-serializing a parsed Value, so a cached line and a live SSE frame for
the same event agree byte-for-byte -- the fetch_transcript_page other
callers under iris/ depend on is untouched.

client-core: 85 -> 109 tests. cargo test/clippy --all-targets/fmt clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 12:38:59 -04:00
iris 2e00e71552 docs: Iris's 11:39 phone report on the 02:07 build, four open items 2026-09-06 11:42:21 -04:00
irisandClaude Fable 5.1 f802de94b5 Merge worktree-agent-a754368325fa06839 into rustify: DragGesture, pointer capture, edge-to-edge insets
Generalizes drag arbitration into a default-input DragGesture with
pointer capture and CursorSense::Drop, and opts MainActivity into
edge-to-edge so IME insets are redelivered. See e12c708.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:38:24 -04:00
iris 9717d1c4b0 docs/RUST.md: 2026-09-06 orchestrator plan for the P0 defects and the P1 prerequisites 2026-09-06 11:37:32 -04:00
iris 9458f443ad Merge remote-tracking branch 'origin/rustify' into worktree-agent-a754368325fa06839 2026-09-06 02:10:55 -04:00
irisandClaude Fable 5.1 e12c708246 iris: generalize drag arbitration into a default-input DragGesture, with pointer capture and Drop
Iris asked (2026-09-06) that dragging be part of iris's default input
system rather than duplicated per app: "anything that provides good
performance and can be generalized well is part of iris rather than the
app." DragArbiter and VelocityTracker (both already in iris::sense) are
now bundled into a new DragGesture, which also takes exclusive pointer
capture (UiRenderState::capture_pointer/release_pointer/captured_pointer)
the moment a gesture commits to panning or selecting, and delivers a new
CursorSense::Drop -- not PressEnd -- to the captured widget when the
button lifts, wherever on screen that happens to be.

This directly targets the phone bench's "finger flings do nothing":
per-widget hit testing silently drops a gesture the instant the pointer
moves off every registered region, which a fast pan/fling does routinely
(crossing several virtualised rows, or ending off the loaded content
entirely) -- so PressEnd, and the velocity/fling-start decision hanging
off it, was frequently never delivered at all. Capture targets List's own
stable id (List::key_at resolves the row-under-pointer from its
extents), not a row's, since List retires rows mid-drag as content
scrolls.

transcript-ui::Selection::drag now only decides pan-vs-select from
DragGesture's outcome; row.rs's per-row registration is only ever a
gesture's first frame, with lib.rs registering the List-level
continuation once. New tests: sense_tests.rs's two pointer-capture
regressions, list.rs's replacing_the_last_row_many_times_does_not_leak_primitives
(a P0 stale-primitives diagnostic -- passes, pinning the widget-arena
layer as not the leak). MainActivity.java opts into edge-to-edge
(Window::setDecorFitsSystemWindows(false), API 30+, no new dependency)
so window insets are redelivered on every change including a pure IME
toggle -- the named-but-untried fix for the phone bench's "keyboard:
could not be shown" and the emulator's identical non-confirmation.

cargo fmt/clippy/test clean across the iris workspace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:10:48 -04:00
iris 543f6d92f0 Merge worktree-agent-a9002910a315fe719 into rustify: composing text, tap-vs-swipe focus, composer rebuild, atlas reset 2026-09-06 02:08:12 -04:00
iris 27ca5b2349 Merge remote-tracking branch 'origin/rustify' into worktree-agent-a9002910a315fe719 2026-09-06 02:03:04 -04:00
irisandClaude Fable 5.1 20b12255e1 iris/android: composing text sync, tap-vs-swipe focus, composer rebuild, atlas reset on app-switch
Four fixes from Iris's phone report on the dc01f88 build, plus her same-day
follow-up on swipe-vs-tap:

- android/ime.rs: InputConnection now calls InputMethodManager.updateSelection
  after every edit (new update_ime_selection, called from after_input) -- Gboard
  was holding keystrokes back with nothing telling it the app's selection/
  composing region had moved, which read as "doesn't enter it until I hit
  space, doesn't move the caret". New unit tests in widget/text/edit.rs cover
  the buffer-level composing/commit/delete/selection operations directly.

- attr.rs: Selector/Selectable rewritten around a shared on_press dispatcher
  over PressStart/Pressing/PressEnd instead of click_or_drag(), so a field
  that isn't already focused only grants focus (and requests the IME) on a
  completed tap -- press and release with no frame past DRAG_SLOP. A drag
  is never consumed, so whatever is behind the field still sees it. New
  FocusHost::is_focused (both platform impls) and TextEdit::press_origin
  back this. Verified on the emulator: dumpsys input_method's mInputShown
  stays false after a swipe over the composer, true after a tap.

- iris_core: GlyphAtlas::clear()/Textures::reset(), called together from
  android/view.rs's surface_changed exactly when a genuinely new renderer is
  built (app-switch, not the keyboard-resize path that already reuses the
  renderer) -- both CPU-side caches otherwise kept pointing at the old,
  destroyed device's textures. Verified on the emulator: home, reopen, every
  glyph still on screen.

- transcript-ui/composer.rs: rebuilt as one widget (unchanged Stack{rect,
  span} idiom, capped at ~6 lines via MaxSize + .scrollable(), wrapped in one
  Pad whose bottom Composer::set_bottom_inset rewrites in place so the bar
  sits on the IME or nav-bar inset with no rebuild -- rebuilding would drop
  focus/selection/in-progress text). Wired from bench_client.rs's existing
  on_insets_changed.

A second, deeper bug found while verifying the composing fix is NOT fixed
this pass: composed text never becomes visible at all. A new layout_tests.rs
test proves the widget tree's own region math is correct across a keyboard
resize, ruling that out; RUST.md's P0 box has the full writeup and what to
check next (UiRenderState::redraw's single-widget path, or something
force-gles-specific -- this AVD has no Vulkan adapter to rule that out with).

cargo fmt/clippy/test --workspace and cargo ndk clippy all clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:03:00 -04:00
irisandClaude Fable 5.1 71a3fae655 IRIS_TODO.md: streaming re-lays out the whole message, from the phone's bench v2
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:35:26 -04:00
irisandClaude Fable 5.1 c3984da623 docs/bench: iris bench v2 report from Iris's phone, verbatim, with her observations
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:34:39 -04:00
irisandClaude Fable 5.1 2e3f4ada38 Merge iris fling/jitter fix + Benchmark v2 + header/ime follow-ups into rustify
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:23:50 -04:00
irisandClaude Fable 5.1 03c6be80a3 iris android-app: header-duplicate investigation, ime-inset fix for keyboard confirmation
Two follow-ups after the keyboard/dp/header pass, both requested against
the P0 box:

(a) The header row rendering a second time inside the transcript area
after a keyboard-triggered resize: reproduced reliably (tap the composer,
screenshot after the keyboard opens). Ruled out one concrete hypothesis --
on_insets_changed rebuilding top_bar on every ime_bottom change, unrelated
to the header's own status-bar padding -- with a guard (last_top_pad) that
reproduced the identical duplicate afterward, so repeated rebuilding is
not the cause. Kept the guard as a real (if insufficient) fix for needless
rebuilds. Not root-caused: Span's two-phase provisional/real draw and the
redraw_all-vs-redraw_updates split are the two live suspects, but pinning
which one (or something else) produces the duplicate needs instrumenting
draw_inner directly or the phone. Full writeup in RUST.md's P0 box.

(b) Why on_insets_changed's ime_bottom never confirmed the keyboard being
shown, on either the auto-diagnostics or the new bench keyboard phase:
MainActivity.java uses windowSoftInputMode="adjustResize", under which
WindowInsets.Type.ime()'s own inset amount is defined to read zero (the
window already resized to avoid the overlap that inset would describe) --
the same trap AGENTS.md already names for the Compose side. Fixed to read
insets.isVisible(ime()) instead, a boolean unaffected by resize-vs-pan.
This alone did not make the callback re-fire on this emulator, which
still shows no insets callback after the initial one at attach -- named
but unconfirmed hypothesis: a non-edge-to-edge Activity may not get insets
redelivered for a pure IME toggle handled via resize, needing an edge-to-
edge opt-in this pass did not attempt given the risk to adjustResize's
own behavior.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:23:36 -04:00
iris 4afc453faa Merge remote-tracking branch 'origin/rustify' into worktree-agent-a16b22e34539b810e
# Conflicts:
#	iris/android-app/src/bench_client.rs
#	iris/android-app/src/bench_jni.rs
2026-09-06 01:05:18 -04:00
irisandClaude Fable 5.1 1aab61bf26 iris android-app: Benchmark v2 -- fling, type and keyboard phases
Implements RUST.md's "Benchmark v2" spec in bench_client.rs: fling (8 out
+ 8 back at 12,000px/s through List::fling, waits for !is_scrolling()
capped 3s, reports travel as row index + offset via List's new
anchor_position_display), stream (unchanged), type (the 600-char P0
constant, one char per 50ms into the composer's real TextEdit via .set(),
then deleted), and keyboard (5 show/hide cycles via bench_jni.rs's new
InputMethodManager calls, confirmed from on_insets_changed's real
ime_bottom transitions rather than assumed from the JNI call returning).

FrameReport gained mark_phase/phase_stats/late_at_hz (iris/core) so the
report can show a per-phase block (frames, late%, p50/p90/p99, worst)
against the display's real refresh rate (bench_jni's new
refresh_rate_hz), matching the shape docs/bench/compose-phone-v2 uses.
RING_CAPACITY bumped 4096->16384 since a full v2 run is ~3,000+ frames.

Found and fixed a real deadlock while wiring this up: read_from_state
(a new helper that gets a value back out of a spawned task's ctx.update,
which has no return channel of its own) only worked for its first call in
a chain, because nothing called redraw.request_redraw() after enqueueing
later ones -- nothing then drains the task channel to run them. Every
call now triggers its own redraw.

Verified end to end on this checkout's x86_64 emulator (force-gles, cold
boot): fling/stream/type all report populated phase blocks; keyboard's
show never got a real on_insets_changed confirmation this run (see
follow-up work). Full report and travel numbers go in RUST.md's P0 box
next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:02:04 -04:00
iris dc01f88d75 Merge branch 'worktree-agent-a1ff0294b6c29127e' into tmp-merge 2026-09-06 00:54:21 -04:00
iris c589a75fa0 Merge remote-tracking branch 'origin/rustify' into worktree-agent-a1ff0294b6c29127e
# Conflicts:
#	docs/RUST.md
2026-09-06 00:54:06 -04:00
irisandClaude Fable 5.1 4b62cc642e docs/RUST.md: emulator verification results for the keyboard/dp/header fixes
run-bench.sh end to end clean (24/24 swipes, 400/400 events); header
background confirmed by screenshot; the keyboard wipe fix confirmed two
ways (a forced wm size resize and an actual soft-keyboard open, both real
surface_changed triggers, text intact both times).

Also records two things found during this verification and not fixed:
the top button row appears to render a second time, out of place, after
a keyboard-triggered resize, and a tap aimed at the field below can land
on it instead -- and the keyboard diagnostics auto-capture never fired in
this session. Neither is root-caused; explicitly not attributed to this
pass's changes without more evidence, per the standing rule against
blaming ambient failures on your own code without measuring first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:51:27 -04:00
irisandClaude Fable 5.1 80c2eadec9 docs: record the keyboard-wipe fix, the dp unit and the header fix
docs/IRIS.md's 2026-09-06 entry (public API), docs/LAYOUT.md's "Density:
Len::dp" design section, IRIS_TODO.md's density-unit item ticked, and
docs/RUST.md's P0 box gets the investigation: the keyboard-wipe
hypothesis and confirmation, the blur root cause and why the dp unit
turned out to be the same fix, the header cause, and what remains
unverified (an emulator screenshot of the keyboard fix, and Iris's real
phone).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:40:59 -04:00
irisandClaude Fable 5.1 0b587629e6 iris/android-app bench: auto-capture diagnostics when the keyboard opens
So Iris can get a report off the phone even if the keyboard wipe (or
some other keyboard-triggered regression) is still present on whatever
build she is holding, independent of whether the on-screen Diagnostics
button itself is drawing.

on_insets_changed edge-triggers on ime_bottom becoming non-zero, waits
KEYBOARD_DIAGNOSTICS_DELAY_MS (500ms, long enough for the resize and a
couple of frames to settle) via a spawned task, then
capture_keyboard_diagnostics reuses show_diagnostics's exact report text,
logs it, copies it to the clipboard unprompted, and shows it through a
new PlatformHandle::show_diagnostics_overlay call into
IrisView.showDiagnosticsOverlay -- a plain TextView + Copy/Close panel
added over the existing IrisView (not replacing it, unlike
showRendererError's one-way trip) so it draws independently of whatever
iris's own renderer is doing, and Close returns to the still-running
session underneath.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:39:14 -04:00
irisandClaude Fable 5.1 3163256d2c iris/android-app: opaque header background, header sizes onto dp
Iris's phone report (build a9232ac): "the header buttons have nothing
behind them and overlap the transcript text." Only each button's own
rect painted anything, so the gaps between and around them (and the
status-bar strip above) showed CLEAR_COLOR (black) one layer back, and
the row's reserved height was three abs (physical-pixel) button boxes --
smaller, on a dense phone, than the dp-correct size the transcript below
now uses post the previous two commits, which is what reads as overlap
once the two disagree.

Fixed with a HEADER_SURFACE rect stacked behind the whole button row
(not just behind each button), and every non-text size in the header
(button padding, row height, the report field's padding) moved from a
bare number to dp(...), so the row's reserved height in the outer
Span::DOWN matches what is actually painted. The list/report field
already sit below the header in that same Span::DOWN, not behind it --
no stacking change needed there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:35:45 -04:00
irisandClaude Fable 5.1 6102e0d4d9 iris: a dp length unit, resolved against density; crisp glyphs at physical size
Iris asked for this 2026-09-06 (IRIS_TODO.md, "a third length kind beside
relative and pixels ... a unit resolved against the display's density at
layout time"): before this, a Len was abs (physical pixels) or rel/rest
(a fraction of the parent), and the only way to make a design size look
the same physical size on a denser display was a single global multiply
applied after layout -- which the previous commit found is also what
made text blurry.

Len gains a `dp` field, resolved against a `density: f32` (physical
pixels per dp) now carried on UiRenderState/Painter
(`UiRenderState::set_density`/`density()`, `Painter::density()`) and
threaded through every `apply_rest`/`to_uivec2` call site. `len_fns::dp`
/ `Len::dp` construct one, exactly parallel to the existing `abs`/`rel`/
`rest`. A bare number is unaffected (still `abs`, physical pixels) --
`dp` is opt-in.

Text: `TextBuffer::shape` now takes `density` and multiplies
`font_size`/`line_height` (and any span override) by it before handing
them to parley, so the size that reaches the shaper and the rasteriser
(`TextData::place`) is the display's real physical size -- the atlas
holds a bitmap at the resolution it is actually shown at, instead of a
low-resolution one stretched afterward. `GlyphKey.size` already keys on
the resolved `font_size`, so a cache entry is naturally per physical size
with no further change. `TextData` also carries its own `density` copy
for `TextEditCtx::layout` (cursor movement/hit-testing), which shapes
text from an input callback with no `Painter` to read it from.

`Span::gap` and `Padding`'s four sides move from bare `f32` to `Len`, so
`.gap(dp(4))`/`.pad(dp(10))` work the same way any other size does; a
bare number still means physical pixels, unchanged.

Migrated transcript-ui's non-text sizes (row gap/padding, composer
padding) and one example to the new unit, per IRIS_TODO.md's "done when"
list. Android's own density (`DisplayMetrics.density`) is wired to both
copies in `new_peer`; the winit backend has no per-monitor density wired
up yet and stays at the default (1.0).

docs/IRIS.md, docs/LAYOUT.md and IRIS_TODO.md updated next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:35:38 -04:00
irisandClaude Fable 5.1 f0da383e28 iris/android: reuse the renderer across a surface resize, fix the keyboard glyph wipe
Hypothesis confirmed by reading the path end to end before changing
anything: surface_changed fires on every SurfaceView size/format change,
not only a genuinely new Surface -- showing the IME under adjustResize
resizes the same surface through this exact callback. The handler
unconditionally dropped AndroidRenderer and rebuilt it via
AndroidRenderer::new, which allocates a brand-new, empty glyph atlas and
fresh GPU buffers, while iris_core's CPU-side glyph cache kept the UV
coordinates it had already handed out against the *old* atlas -- so every
glyph drew from a rectangle pointing into a texture that had just been
recreated empty. Rects never go through the atlas, so they kept drawing:
exactly Iris's report ("rectangles stay; only text disappears").

Fixed by reusing the existing AndroidRenderer (device, atlas, buffers,
bind groups) and only reconfiguring the surface + window uniform via its
existing resize() when a renderer is already live; AndroidRenderer::new
now runs only when surface_changed finds `renderer` already None (a
genuinely new surface, e.g. after surface_destroyed/backgrounding).

While in this path, removed the global logical/physical scale stopgap
(dividing window size, touch coordinates and insets by content_scale)
that the P0 "text too small" fix had added: it is what made text blurry
next (a glyph rasterised small then stretched by the NDC mapping onto the
real physical framebuffer). Window size, touch and insets are physical
pixels throughout now, matching AndroidRenderer's own swapchain
resolution; density is resolved per-length instead (next commit).
LogicalInsets renamed to WindowInsets to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:35:22 -04:00
irisandClaude Fable 5.1 2d3695a1d3 Merge iris fling/jitter fix into rustify
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:20:39 -04:00
irisandClaude Fable 5.1 f06ee259b4 iris: List::fling with Android's spline physics, and fix the drag-slop scroll jitter
Adds VelocityTracker and a port of AOSP SplineOverScroller's fling curve
(FlingCalculator, cited at the definition) to iris::sense, and wires
List::fling/is_scrolling/cancel_fling/tick_fling through
Selection::drag's release path -- a pan's release now decelerates instead
of stopping dead on the finger lifting, matching IRIS_TODO.md's "swiping
has no momentum" ask. Clamped at the loaded content's start/end and
cancelled by the next touch-down.

Also fixes the scroll jitter DragArbiter's slop release caused: crossing
DRAG_SLOP applied the whole pre-threshold drag (measured from press_start)
in one step, since nothing pans while a gesture might still resolve to a
selection. Now only the excess past DRAG_SLOP is applied on that frame,
the same way Android's own touch handling consumes touch slop rather than
replaying it.

Root-caused by reading DragArbiter's state machine and covered by new
unit tests (fling distance against the closed-form spline result within
1%, cancel-on-touch, start/end clamp, the slop-crossing regression); no
emulator was used this pass, so an on-device trace/feel-check is still
open, and Benchmark v2's four-phase bench_client.rs spec was not
attempted. docs/IRIS.md, docs/IRIS_TODO.md and docs/RUST.md's P0 box
record what's done and what's left.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:20:24 -04:00
irisandClaude Fable 5.1 560a74caf8 docs: record the phone-report fixes, follow-ups and the bundled-font API
RUST.md's P0 box gets Iris's first real-phone report (no crash) and the
four defects it found (glyph-wipe-on-first-touch, missing bold glyphs,
text far too small, status-bar inset not applied), what was fixed and
how it was verified on the emulator, and what's still open (item 1's
root cause, and the top-row height anomaly noted in the last commit).

IRIS_TODO.md gets a new "From the phone, 2026-09-06" section for the two
items explicitly deferred to a follow-up agent: no scroll momentum/fling,
and occasional jitter scrolling down.

IRIS.md gets the public-API entry for TextData's bundled fonts/
font_diagnostics, UiRenderNode::new/resize's new window_size parameter,
AndroidUiState::content_scale, AndroidAppState::on_insets_changed, and
iris_core::WgpuErrorLog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:59:40 -04:00
irisandClaude Fable 5.1 fd7e17523d iris/android: fix layout/shader unit mismatch left by the density-scale commit
surface_changed's self.render.resize(...) -- UiRenderState::output_size,
what every widget's absolute PixelRegion (a fixed .height(56), notably)
is computed against -- was still being handed raw physical width/height
after the previous commit switched AndroidRenderer's own size()/resize()/
new() to logical (physical / content_scale) for the shader's window
uniform. That split layout and the shader into two different units:
layout placed a "56"-unit row inside a ~2219-physical-unit-tall canvas
(an absolute box, still exactly 56 units), the shader then divided that
same 56 by a ~845-unit *logical* window dimension -- found on the
emulator by measuring a fresh install's top button row at ~40 physical
px against the ~147px `56 * content_scale` predicts. Proportional
(rest(n)) sizes hid the mismatch by adapting to whichever total they were
given; only fixed sizes exposed it. Now divides by content_scale here
too, matching every other call site.

Verified on this checkout's emulator (EMU_GPU default, force-gles):
run-bench.sh completes end to end (frames=691, 24/24 swipes streamed
400/400 events) and a fresh-install screenshot shows visibly larger
text than before this and the previous commit, with the top row's own
sizing still worth a closer look on a real device -- see RUST.md's P0
box for what remains unverified there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:57:24 -04:00
irisandClaude Fable 5.1 c7682297fa docs/bench: Compose bench v2 report from Iris's phone, verbatim
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:49:02 -04:00
irisandClaude Fable 5.1 27511302f2 iris/android-app: Diagnostics control, top-bar status-bar padding, cargo fmt
Adds a third "Diagnostics" button to the bench screen's top row, filling
the existing benchmark-report TextEdit (so the existing "Copy report"
button and clipboard path work on it unchanged) with adapter identity,
font resolution, atlas view count, wgpu errors seen so far and the frame
report -- RUST.md's P0 box, "a named Diagnostics control ... copy this
and send it to Iris." Logs the same font-resolution summary once at
startup too.

Wires BenchClient::on_insets_changed (the new AndroidAppState hook) to
rebuild the top button row with Padding::top(insets.top), through a
WidgetPtr slot (top_bar) so it can be swapped once the status-bar inset
is known -- fixes RUST.md's P0 box, "the status-bar inset is not
applied," where the two top buttons sat directly under the status bar
because nothing in this file read insets().top at all.

cargo fmt --all across the touched files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:44:43 -04:00
irisandClaude Fable 5.1 184a6c5b33 IRIS_TODO.md: a density-independent length unit, asked for by Iris
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:43:16 -04:00
irisandClaude Fable 5.1 5b2ca039f1 docs/RUST.md: bench v2 spec and the emulator smoke run
Iris's ask (2026-09-06): the fling should travel much faster for
stress-testing, plus typing and keyboard phases. Written once into the
P0 box so the iris agent implements the identical four-phase spec --
constants, ordering and report shape -- rather than a second one that
looks the same but isn't.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:41:03 -04:00
irisandClaude Fable 5.1 a8d24553d5 app: bench v2 -- a real fling, typing and keyboard phases
Iris's ask after using the Compose bench build on her phone: the old
scroll phase used animateScrollBy, which can only ever cover the fixed
distance/time it's given, so it never flings the way a real fast swipe
does. BenchRun.run now has four phases: fling (8 flings out + 8 back
through the list's own FlingBehavior at 12,000px/s), stream (unchanged),
type (600 fixed characters into the real composer TextFieldValue, then
deleted, to exercise wrapping and the transcript being pushed upward),
and keyboard (five show/hide cycles via WindowInsetsControllerCompat,
each confirmed by isImeVisible rather than assumed).

FrameStats.markPhase/phaseLines slice the same FrameMetrics recording
by phase rather than running a second recorder; debugReport gains a
phaseFrames section ahead of the existing whole-run frames/accounting/
work sections, which are otherwise unchanged.

Also fixes a pre-existing, unrelated break in MainActivity.kt's
benchSessionSummary() -- missing several SessionSummary constructor
arguments from an earlier change -- since it blocked compileBenchKotlin
outright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:40:59 -04:00
irisandClaude Fable 5.1 3b80a88f3b iris/android: content_scale (density), per-frame diagnostics, insets hook
Threads DisplayMetrics.density (read once in new_peer, via the Context
android-view already hands the JNI entry point) through AndroidUiState
as content_scale, and divides by it everywhere a raw device-pixel number
used to reach layout unscaled: AndroidRenderer::size()/resize()/new() now
report logical (physical / density) dimensions to UiRenderNode and to
UiRenderState's own root-layout size, and on_touch_event divides the
incoming MotionEvent coordinates the same way, so touch and layout agree
on units again. This is the fix for RUST.md's P0 box, "text is far too
small" -- a font_size: 16.0 was 16 raw device pixels on a ~3x-density
phone, identical to the desktop fix in the previous commit.

Installs Device::on_uncaptured_error on the Android device (wgpu's
default handler is an unconditional panic outside UiRenderNode::new's
own error scopes) into a new iris_core::WgpuErrorLog, and adds
AndroidRenderer::diagnostics_report() combining adapter identity, font
resolution, atlas view count and the error log into one string for a
future Diagnostics screen. render() now logs a one-line diagnostic
(masks/moves resized, atlas pages grown, image bind-group creates, wgpu
error count) for the first 10 frames after each surface_changed -- the
window RUST.md's P0 box says the glyph-wipe-on-first-touch happens in.

Adds AndroidAppState::on_insets_changed(rsc, LogicalInsets), called from
render() exactly when AndroidUiState::insets() changes (once at startup
for the status bar, again on rotation/IME) -- nothing previously read
insets().top at all, which is why RUST.md's P0 box found the bench
screen's top buttons sitting under the status bar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:40:30 -04:00
irisandClaude Fable 5.1 d8e6bc6e9b iris: bundle Noto Sans for text rendering, apply density scale on both backends
Bundles Noto Sans/Noto Sans Mono (regular/bold/italic/bold-italic, OFL
licensed) into iris-core and registers them ahead of the platform's own
fonts in the SansSerif/Monospace generic-family fallback lists, so text
no longer depends on the platform's font enumeration succeeding or
resolving weight/style correctly. Iris's phone report showed bold spans
rendering as blank gaps of the correct advance width -- the glyph simply
wasn't rasterised -- while the emulator's system fonts happened to
resolve every style; a bundled static-per-style family removes that
platform-dependent step entirely. TextData::font_diagnostics() reports
what was found/resolved, for the startup log and the Diagnostics page.

Also applies a content/device-pixel scale that neither backend had
before: UiRenderNode::new/resize now take the window size explicitly
(logical units) rather than deriving it from the surface's physical
config, so a 16.0 font size is 16 logical units rather than 16 raw
device pixels. Wired on desktop via window.scale_factor() (input events,
window_size, and the render node's own seed); the Android side (density
via DisplayMetrics, touch coordinates, layout root size) is the next
commit.

Also adds WgpuErrorLog and a per-frame atlas-grow counter
(GpuTextures::take_pages_grown), both plumbing for the Android
diagnostics page in the next commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:36:29 -04:00
irisandClaude Fable 5.1 b887a96765 docs/bench: iris's first phone report, before the phone fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:26:09 -04:00
irisandClaude Fable 5.1 2aaa3733c3 docs/bench: the Compose P0 report from Iris's phone, verbatim
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:20:05 -04:00
irisandClaude Fable 5.1 46246ea511 iris: turn the phone bind-group-layout crash into a diagnostic, drop force-gles from phone builds
UiRenderNode::new used to let a wgpu validation error reach the default
uncaptured-error handler and panic, which is what aborted the P0 bench APK
on Iris's phone in AndroidRenderer::new with only "wgpu error: Validation
Error" surviving into the truncated crash report. It now wraps creation in
wgpu error scopes and returns Result<Self, String>; the Android backend
turns a failure into the adapter's identity, the limits/downlevel flags a
layout validates against, and wgpu's own error chain, logged as one logcat
line and shown on screen (IrisView.showRendererError) instead of crashing.

Auditing every bind-group-layout entry against wgpu-core's own validation
source names the likely cause: masks_layout's move_offsets storage buffer
is visible to the vertex stage, which Vulkan grants unconditionally but
GLES gates on the driver's own vertex-stage SSBO support -- and the
delivered APK was built with force-gles, a flag meant only to force the
*emulator* onto GLES for one frame-time measurement, that build-apk.sh's
default feature list applied to every arm64 build regardless of target.
Its default no longer includes force-gles.

Testing the diagnostic (by inducing an artificial validation error) also
found and fixed a real reentrancy bug: calling Activity.setContentView
synchronously from inside a ViewPeer callback re-enters the same peer's
RefCell borrow through onFocusChanged, aborting with "RefCell already
borrowed". Deferred through the same push_dynamic_deferred_callback
mechanism raise_if_enabled already uses.

Full audit, verification, and the named hypothesis are in RUST.md's P0
box ("iris bench crash on the phone, 2026-09-06"); the API change is in
IRIS.md. Nobody on this session has the phone, so this is unconfirmed
against real hardware -- the point of (1) is that the next run says so
either way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:05:07 -04:00
irisandClaude Fable 5.1 a27fbdb029 docs: close I5's three blocked verifications (24/24-swipe, backend isolation, cold-boot bench)
Ran three clean iris-scroll.sh passes on a cold -gpu host boot (all
24/24 swipes confirmed scrolling via clustered render() timestamps, not
inferred from frame count) and retook the host-GPU table's iris row as
a best-of-three. EMU_GPU=software + force-gles still cannot produce a
GLES number on this hardware -- after the earlier compute-limit crash
was fixed, device creation now aborts on max_storage_buffer_binding_size
instead (SwiftShader ES 3.0 has no SSBOs, and shader.wgsl reads four
var<storage> buffers unconditionally), so the SwiftShader-Vulkan-vs-GLES
question is closed as structurally unanswerable rather than answered.
A fresh cold-boot run-bench.sh reading for P0's bench build is in line
with the earlier warm-AVD readings, closing that box's own caveat too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:39:27 -04:00
irisandClaude Fable 5.1 c07d544aeb event-model, client-core, transcript-ui: carry main's LimitReached event
The merge that brought main into rustify added Event::LimitReached to the
server's drivers, but on this branch the enum lives in event-model, which
the merge left without it, so ai-server (and ui-sandbox.sh) did not build.
Definition copied from main's driver.rs; the fold mirrors TranscriptItems.kt's
LimitNote; the iris row shows the epoch until P1 brings a time formatter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:18:38 -04:00
irisandClaude Fable 5.1 46d3a6fd41 docs: record the streaming-rebuild fix, its numbers, and the new scripts
RUST.md's P0 box gets the fix, the before/after streaming-phase numbers
(with their caveats), the build-apk.sh/run-bench.sh scripts, and what the
dropout-fix pass's three remaining verifications are blocked on (the
sandbox ai-server currently fails to build, unrelated to this change).
IRIS.md gets the List::replace_back/clear and TranscriptScreen::apply
API entries. AGENTS.md's rigs section gets one sentence on each script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:35 -04:00
irisandClaude Fable 5.1 5655fa8093 iris-android-app: build-apk.sh and run-bench.sh
Wraps the cargo-ndk/Gradle/keystore/apksigner build and the
install/tap-by-label/read-report cycle that P0's work had been retyping
by hand, so it stops costing time and mistakes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:35 -04:00
irisandClaude Fable 5.1 b3b1d47dd6 iris: streaming a transcript event no longer rebuilds the whole screen
Every client (bench_client, transcript_client, desktop-app) refolded and
rebuilt the ~3,200-row widget tree from scratch per SSE event, which is
the streaming-phase cost the P0 benchmark gate would otherwise measure
against a Compose app that updates one row. iris::widget::List gains
replace_back (swap the last row's widget in place, keeping its slot so a
pinned list stays pinned) and clear (the full-rebuild fallback);
transcript_ui::TranscriptScreen::apply diffs the folded row lists and
picks the cheapest update -- unchanged, append, replace-the-last-row, or
(rare regroup) a full rebuild, counted. TextEditCtx::set_with_spans lets a
row's text and span list land together on a streamed update.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:27 -04:00
iris 50fe4828a2 Merge branch 'worktree-agent-a27094a7db775552a' into tmp-merge 2026-09-05 21:37:12 -04:00
318 changed files with 39494 additions and 26173 deletions

No files matched your search

+7 -3
View File
@@ -1,6 +1,10 @@
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
# every crate in this repo into one workspace -- they are deliberately
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
# from the repo root runs xtask/src/main.rs directly.
# independent (see scripts/run-tests.sh, which cds into each).
#
# `cargo xtask apk` **from the repo root** runs scripts/xtask/src/main.rs.
# The manifest path is relative to the working directory cargo is run from,
# so the root is where it works; this file is found from any directory
# inside the checkout, but the path inside it is not.
[alias]
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
xtask = "run --quiet --manifest-path scripts/xtask/Cargo.toml --"
+5 -3
View File
@@ -64,9 +64,11 @@ components: [
// defaults to this checkout's root, which both the `cargo xtask`
// alias (`.cargo/config.toml`, resolved relative to the working
// directory cargo is run from) and `cargo xtask apk`'s own publishing
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
// both need.
// step (`scripts/build/outputs/apk/<mode>/*.apk`, matching
// discover.rs's `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's
// module doc) both need. The publish directory is `scripts/build`
// rather than `scripts/xtask/build` for exactly that reason: the
// pattern is one directory deep, and the tool moved two on 2026-09-09.
Apk(
name: "shell",
modes: ["release", "debug"],
+6 -9
View File
@@ -9,8 +9,7 @@ local.properties
.DS_Store
server/target/
event-model/target/
client-core/target/
android-shell/target/
app-rust/target/
# E3's native library, built by cargo-ndk straight into the Gradle module
# (RUST.md) -- an artifact, like server/target/ above, not source.
@@ -32,11 +31,9 @@ sessions/
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
iris/target/
iris/android-app/target/
# E5's packaging xtask (RUST.md). `build/` above already covers
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
# The repo root has no Cargo workspace, so this is xtask's own
# intermediate working files (target/xtask/apk/...), not a shared one.
xtask/target/
/target/
# The packaging xtask and the GPU rigs, both under scripts/. `build/`
# above already covers scripts/build/outputs/apk, where `cargo xtask apk`
# publishes for Dev Updater to find.
scripts/xtask/target/
scripts/rigs/gpu-probe/target/
+235 -18
View File
@@ -19,6 +19,28 @@ child process, translated into one common event model.** A new session type
is a new driver — never a session-type branch in shared code (routes,
transcript, app screens).
The second one, for the Rust port on the `rustify` branch: **the phone app
and a planned desktop app share almost all of their code.** Screens,
widgets, folding, paging, config and the network client live in
`app-rust/`'s `client` and `ui` modules, drawn with `iris`; `src/android`
and `src/desktop` are thin entry points that own only what the platform
forces (JNI and the IME on one side, winit and argv on the other). The two
*layouts* will differ, to suit a phone's screen and a finger against a
desktop's screen and a mouse -- but the widgets a layout is made of (a
button, a text field, a list, a card) and the styling (colours, spacing,
type) are one implementation with no per-platform copy. Anything that could
work on both goes in `ui` the first time it is written, and a platform
module growing a widget or a colour is a defect to move, not a convenience
to keep. Iris said this on 2026-09-07; docs/RUST.md carries the details.
The third, from Iris on 2026-09-08: **`iris/` is the UI framework and
nothing else.** Nothing in it may know about a session, a transcript, a
setup or a server; anything that does belongs in `app-rust/`, and the
dependency runs one way only. The port's project code is **one crate**
(`ai-app`) rather than the six it was scattered across — see docs/RUST.md's
"One app crate" for what forced each of the splits that were removed and
the two that remain.
## Layout
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
@@ -29,11 +51,46 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
comment is the HTTP table and the surface's source of truth.
- `event-model/` — the wire shape `server/` and `app-rust/` both depend on,
which is the whole reason it is a crate of its own rather than part of
either: it is the contract between them, so the two agree by construction.
- `app-rust/` — the Rust app, one crate (`ai-app`) with three faces. `src/
client` is everything with no UI in it (the REST and SSE clients, the
transcript cache and fold, the highlighter, the ANSI parser, config and
the enrolment link); `src/ui` is the screens as iris widget trees;
`src/desktop` + `src/bin_desktop.rs` is the winit binary; `src/android`
is the `android-view` entry point and `android-project/` its Gradle app;
`src/shell` is the separate JNI bridge the Kotlin `app/shellApp` calls.
Features pick which face a build is: `screens` (default) for anything
that draws, `shell` for the Compose app's bridge, `bench` for P0's
fixture build. See its `Cargo.toml` header.
- `iris/` — the UI framework, and **only** the UI framework: `core`,
`macro`, the `iris` crate itself, `tabs-ui` (its own demo widget tree)
and `rig-input`. It must not mention anything this product is about.
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
the Keystore-sealed token.
- `scripts/` — everything at the root that was neither a program nor a
document: the three repo-wide shell scripts (`run-tests.sh`,
`test-wg-tunnel.sh`, `wg-setup-host.sh`), `rigs/` (the `gpu-probe` and
`virtgpu-probe` device probes, and `ui-profile`'s two layer-1
profiling rigs), and `xtask/`. **A project's own scripts
stay with the project** — `app/*.sh`, `app-rust/*.sh`, `iris/*.sh` and
`server/enroll-link.sh` did not move (Iris, 2026-09-09: "I only meant
top level sh files").
`scripts/xtask/` is the [cargo-xtask](https://github.com/matklad/cargo-xtask)
convention: an ordinary Rust binary that does build work a shell script
would otherwise do, run as `cargo xtask apk` **from the repo root**
(`.cargo/config.toml`'s alias, whose `--manifest-path` is relative to
the working directory). It packages `app/shellApp` without Gradle
driving it — `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` →
`apksigner` — and publishes to `scripts/build/outputs/apk/<mode>/`,
which is where Dev Updater looks. There is deliberately **no `target/`
at the repo root** any more: there is no workspace there, and what used
to be one was only xtask's own scratch space, now in
`scripts/xtask/target/`.
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
binding and the certificate's SANs (`netif`), owner-only files (`private`),
@@ -51,18 +108,53 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
the opening and stream effects in `SessionScreen.kt`.
- `docs/TODO.md` — the working list.
- `docs/SUBAGENTS.md` — a session's subagents: the wire shape, the
phone's view, and the choices behind the shape.
- `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
branch of the `ai-app-2` clone): what has to be reproduced, the
framework decision, and the ordered experiments with their pass
conditions. Read it before touching anything under that branch.
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`,
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's
own public API log, working list, decisions log, layout/render design,
and texture-atlas design, and the client-core crate's design,
respectively.
- `docs/IRIS_TODO.md`, `docs/LAYOUT.md`, `docs/TEXTURES.md`,
`docs/CLIENT_CORE.md` — iris's open working list, its layout/render
design, its texture-atlas design, and the design of `app-rust`'s
`client` module, respectively.
**These documents are pruned as the work lands, not appended to
forever** (Iris, 2026-09-08: *"remove everything that's already done and
decided… many with checkboxes already ticked off that just fill up
context"*). A ticked box, a finished experiment and a completed review
are deleted once carried out; `IRIS_TODO.md` holds only open items, and
a finished document is removed rather than archived in place. What
survives is what cannot be cheaply re-derived — measurements, dead ends
and failed hypotheses, invariants and their reasons, and the design of
what exists now rather than the route to it.
**There is no decisions log and no design log, and one should not be
started.** `docs/DECISIONS.md` and `docs/IRIS.md` were deleted on
2026-09-09 at Iris's instruction: *"I've decided to instead make
decisions when planning with agents rather than after they do things,
and they're both too long for me to wanna read, + don't cover all the
decisions I'll wanna make about the code anyways. I'll just naturally
run into things for now."* So raise a choice **while planning it with
her**, when the direction is still cheap to change; otherwise decide it,
put the reasoning at the code it governs, and carry on. TODO lists are
still wanted — a list of open work is useful, a list of finished work
is not.
- `docs/SCROLL.md` — how anything in iris scrolls: one
`ScrollController` holds the position, the gesture, the fling and the
pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own
one each through the `Scrollable` trait. Read it before touching
`scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that
pans, flings or lays out a long list.
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
`service: Managed(…)`, supervised by Dev Updater's own implementation
rather than a script kept here) and the APK, in parallel. It points at
rather than a script kept here), the Compose app and the shell APK, in
parallel. **It does not publish either benchmark APK.** Phone benchmark
builds live in the separate `~/repos/ai-app-bench` repository: build here,
copy the verified artifact to that repo's `compose/` or `iris/` Gradle-shaped
path, then commit and push that repo. Dev Updater pulls the committed APK
from there; pushing `ai-app-2` alone cannot update its benchmark card. This
file points at
`resources.ron`, which is *ours* rather than Dev Updater's — it names
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
offer them. Note what deleting the config directory takes with it: the CA
@@ -85,12 +177,33 @@ the **Mono** face, where every glyph is one em square, which is what makes
two icon buttons the same width without either being given one — and why
`GLYPH_SIZE` is smaller than it looks like it should be.
**The Rust app does the same, from its own subset**:
`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`,
with the codepoints named in `iris/core/src/icon.rs` and drawn as text
with `Family::Icons`. Same rule about the two lists agreeing (there is a
test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material
Design family so an icon means the same thing in both apps. Its subset is
separate rather than shared because subsetting only what one app draws is
the point. This is the **only** font iris bundles — body and monospace
text come from the platform (decided 2026-09-07), and an icon
is the opposite case: a small closed set of codepoints no system font is
guaranteed to have.
## Checking your work
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
`server/`), plus `cargo clippy --all-targets` and `cargo fmt`. The build
stays warning-clean and rustfmt-clean at the defaults — there is no
`rustfmt.toml` and there should not be one.
- **Commit completed work.** Once a coherent piece of work has passed its
relevant checks and has no known major issue or unresolved design decision,
commit it rather than leaving it in the worktree. Keep independently
completed slices in separate commits.
- **Rust**: `./scripts/run-tests.sh` from the repo root runs `event-model`,
`server/` and `app-rust/`; `cd iris && cargo test` runs the framework's
own suite, which is slower and not about this product. Each workspace
also gets `cargo clippy --all-targets` and `cargo fmt`. The build stays
warning-clean and rustfmt-clean at the defaults — there is no
`rustfmt.toml` and there should not be one. `app-rust/`, `iris/`, and the
UI profiling rig use the rolling nightly channel through per-directory
`rust-toolchain.toml` files; `server/` and `event-model/` are stable.
- **App**: from `app/`,
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
:androidApp:compileDebugKotlin :androidApp:lintDebug
@@ -151,7 +264,7 @@ two icon buttons the same width without either being given one — and why
whether it was continued or reset (`stream backlog:`). That is the only
place "how far had this phone fallen behind" is answerable — the app sees a
window arrive and cannot tell.
- **`./test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
- **`./scripts/test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
network namespaces inside one machine and drives the server through it — a
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
involved. That is how to verify the wg0-only posture.
@@ -161,7 +274,7 @@ two icon buttons the same width without either being given one — and why
Each exists because something was invisible without it.
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
and DECISIONS.md's 2026-09-05 entries), the phone benchmark gate Iris
and the 2026-09-05 decisions), the phone benchmark gate Iris
asked for before porting continues: a deterministic, checked-in synthetic
transcript (`app/bench-fixture/generate.py`, never a real one) that both
this app and iris open with no server, so a frame-time comparison
@@ -261,6 +374,113 @@ Each exists because something was invisible without it.
framework, from `atrace` text output with no trace processor needed. It is
how the cost of a layout node per link was attributed to the framework
rather than guessed at.
- **`app-rust/build-apk.sh [debug|release] [--abi ...] [--features
...]`** builds the Rust app's cdylib (`cargo ndk` from `app-rust/`,
straight into `android-project/app/src/main/jniLibs/`) and its APK
(Gradle, from `android-project/`) in one step and verifies the result
(`aapt2`/`apksigner`), and **`app-rust/run-bench.sh [--apk PATH]`**
installs it on this checkout's own emulator, taps "Run benchmark" by
label, and prints the report -- written so the P0
build/install/tap/read-report cycle stops being retyped by hand each
time (docs/RUST.md's P0 box). It passes `--no-default-features`, so
`--features` alone decides what is in the `.so`; that is what keeps the
1.9 MB bench fixture out of a build that did not ask for `bench`. A phone
build is published only by replacing
`~/repos/ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`
and pushing the **bench repository**, not this checkout.
- **iris's three test layers** (docs/RUST.md's "Three test layers" has
the commands and what each cannot answer): test at the cheapest one
that can answer the question. `cd app-rust && cargo test` runs the real
transcript screen over the bench fixture with **no window, no
compositor and no GPU** (`iris::harness`), on a clock the test owns and
a gesture replayed from a `t_ms action x y` file under
`app-rust/touch/` -- which is how the batched 120Hz
flick a finger actually makes is testable at all, since a `ui-trace`
swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone
--dir ../app-rust --shot …` opens the same screen in a window at the
phone's own size and density for looking at, and `--replay FILE` drives
the same recording into it (`--dir` names the workspace to build in,
since the rig lives in iris and the app's examples do not). The emulator is for JNI, the IME, insets, the surface
lifecycle and one verification run before a build goes to the phone --
not for iterating on layout.
- **`scripts/rigs/ui-profile/`** holds the two layer-1 profiling rigs, in
a crate of their own so a rig's dependencies stay out of the app's
(Iris, 2026-09-09: *"Rigs should probably all be in their own crate so
dependencies and such don't get mixed"*). Run either from that
directory; both are `#[ignore]`d and assertion-free, so `run-tests.sh`
neither runs them nor can fail on them, and both need **release or the
numbers mean nothing**.
- **`tests/frame_profile.rs`** is what a frame costs on the CPU,
at layer 1 -- `cargo test --release --test frame_profile -- --ignored
--nocapture`. Two runs: a fling over the bench
fixture eight times out and back, and a reply streaming into it one
event at a time. Text shaping dominates, which is why the profile is
meaningless unoptimised. It cannot answer anything about the GPU, the
swapchain or the phone's own clock.
What it established on 2026-09-09, worth not re-deriving. A **fling**
is not CPU-bound: only about one frame in six lays anything out (the
rest are moved on the GPU through `move_offsets`), and the
multi-millisecond spikes are all in the *first* pass over a stretch of
transcript -- every later pass over the same rows is p99 0.26ms. A
**streamed event** is, and it is not where it looks: folding the event
is 0.35ms and applying the diff to the widget tree is 0.41ms, while the
*frame* is 3.86ms here and 9.5ms on Iris's phone. (The fold was the
hypothesis, from `foldEvent`'s Compose lesson under "Things that have
bitten"; measuring it is what ruled it out.) That frame is one
`TextBuffer::shape` of the block a delta landed in, and **the fixture's
is 14,888 characters in a single block** -- against a largest-ever
1,580 across 7,706 blocks of real replies. So the stream phase's number
is a property of the fixture, not of streaming; docs/RUST.md's
"Incremental text" has the measurements and why parley cannot help.
The last two runs (`where_a_streamed_deltas_cost_is`,
`what_the_fixture_streams`) exist to keep that answerable: what a delta
costs to re-split and re-compare, and what the fixture actually
streams.
- **`tests/arena_churn.rs`** is what a frame costs to *upload* -- the half
of a frame layer 1 builds and never performs, and so the half
`frame_profile.rs` cannot see at all. It prints three numbers per GPU
array per frame, and the point of the rig is that no two of them alone
are honest: **floor** (entries whose bytes actually differ, found by
diffing), **uploaded** (what iris really writes, read from the same
`Dirty` sets `UiRenderNode::update` consumes), and **whole** (what the
old code wrote whenever anything changed). A gap between the first two
is over-marking; one was 122x and invisible until both were printed
side by side.
What it established on 2026-09-09, and what the three optimisations it
drove were. Uploading the whole arena on any change cost **758 MB over
a fling and 1.2 GB over 401 streamed deltas**, p50 3.0 MB per streamed
frame. Three things were wrong and each is now guarded by this rig:
`ArrBuf` reallocated on every length change, so adding one glyph made
the buffer's contents undefined and forced a full rewrite; a redraw
freed its primitives and pushed new ones, which -- since freed slots
are only reusable next frame and provisional layout nested -- grew
the arena to **127,443 slots for 11,569 live primitives**; and nothing
tracked *which* entries changed. Now: the stream arena is 11,569 slots
for 11,569 live, and every array uploads within a hair of its floor.
The CPU half improved with it, since the freeing and renumbering
went away: a streamed frame is p50 1.39ms, from 2.20ms.
The remaining layout cost was then removed at the framework boundary:
`Painter::set_child_offset` gives a container one retained coordinate slot
for its child subtree, and `LazySpan` keeps row boxes stable behind it.
Pinned growth now uploads instances at **1.1% against a 1.1% floor**, from
71.9% against 71.8%; p50 instance upload is **1,488 bytes**, from 176,496.
`Primitives` also cancels dirty marks for provisional writes restored before
upload, so CPU-only layout states never become GPU work.
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader
in software -- while its GLES *is* the host's real GPU through virgl at
ES 3.1, so an ordinary build's runtime fallback lands there by itself
and nothing should pass `force-gles` to arrange it. The Vulkan path is
verified on the desktop build and on Iris's phone. Do not boot the
emulator with SwiftShader Vulkan to "test the Vulkan path": that
measures a software rasteriser and steers iris away from the one
hardware-accelerated backend it has there. Every run says which adapter
drew it (`iris renderer:` in logcat, printed by `run-bench.sh`); read
that line before reading a number.
### Driving the UI
@@ -326,13 +546,11 @@ thing to suspect first if a remote spawn ever mangles an argument.
## Where things run (host vs this VM)
The machine itself — the two boxes, the shared `~/repos` mount, and why the
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that
means here:
This checkout runs in a VM while production runs on its host:
- **`ai-server` belongs on the host in production.** That is where the LAN
address the phone can reach is, and where WireGuard terminates.
`wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
`scripts/wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
there with `sudo WG_ENDPOINT=<ddns name>`.
- **The tunnel and the real phone can never terminate in the VM**, because
nothing outside can open a connection into it. Phone bring-up is host work.
@@ -420,8 +638,7 @@ where it was instead of half-deleted.
## Things that have bitten
Project-specific only — a lesson that would bite any project on this machine
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
Project-specific only; keep cross-project machine notes out of this file.
- **tracing caches callsite interest process-wide.** A test that hits a
`tracing::warn!` with no subscriber installed can poison the interest cache
-44
View File
@@ -1,44 +0,0 @@
# Decisions awaiting review
Choices made while working autonomously, for Bryan to keep or change. Each
says what was picked and why; the detail is in the design doc it names.
Delete an entry once it has been looked at.
## Subagent views (2026-09-05, `SUBAGENTS.md`)
Made on my own judgement, limited blast radius:
1. **A subagent is a transcript, not a session.** It has no process,
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
and stored under the session's directory, so deleting the session takes
it. Alternative rejected: registering it as a session of its own, which
would give it a card in the main list and a driver that can do nothing.
2. **Read-only view is the session screen minus its controls**, rather than
a second, simpler transcript screen. Keeps paging, caching, selection
and rendering in one place. Cost: a `readOnly` mode threaded through
`SessionScreen`.
3. **The list only carries a count.** Each session row says how many
subagents it has; their titles and statuses are fetched when the card is
expanded. Keeps `GET /sessions` from reading every subagent transcript.
Consequence: an expanded card's statuses refresh with the list, not live.
4. **Expanded/collapsed is remembered per session on the phone**, not on
the server. Collapsed by default, per the transcript convention that new
things arrive collapsed.
5. **Subagents of imported sessions are not shown.** The import path still
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
are not read. Only subagents run while this backend was watching exist.
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
paid turn to exercise.
Deferred, because they reach further than this feature:
- **Live status on the list.** Whether the session list should follow a
stream at all (it refreshes on demand today) decides whether subagent
status can ever be live there. Not changed.
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
in its transcript and are not given transcripts of their own. Supporting
that is the same mechanism one level down, but the UI would need nested
expanders.
- **The subagent status row says "context unknown".** Nothing measures a
subagent's context; the row could leave it out rather than admit it.
-1081
View File
File diff suppressed because it is too large. Load diff
-36
View File
@@ -1,36 +0,0 @@
[package]
name = "android-shell"
version = "0.1.0"
edition = "2024"
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
# why those two classes cannot be anything but Java/Kotlin, registered from
# the manifest by name). Everything they would otherwise have done in
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
# picking a session for a share -- is here instead, built on `client-core`
# so the networking and parsing are not duplicated a third time next to the
# server and the Kotlin app.
#
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
# on a normal host target without an Android NDK toolchain, the same
# posture `client-core` and `server` already have.
[lib]
name = "android_shell"
crate-type = ["cdylib", "lib"]
[dependencies]
client-core = { path = "../client-core" }
jni = "0.22"
log = "0.4"
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
# without a backend installed -- so without this, every recoverable error
# at a native entry point would be silently dropped rather than reaching
# logcat. Android-only: nothing else here needs it, and it does not build
# off-device (see `notify::ensure_logger`'s call site, the only place this
# is used).
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.15"
-152
View File
@@ -1,152 +0,0 @@
//! Thin wrappers around the five `Env` calls this crate makes constantly
//! (a class name, a method name and a signature, all as plain `&str`).
//!
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
//! here uniformly rather than switching to the compile-time `jni_str!`
//! literal macro call by call -- these are a handful of short, one-off
//! lookups, not a hot loop, so the difference is not worth two code paths
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
//! `FieldSignature`, which is why those go through
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
//! parsed form is what lets these calls skip re-validating the signature
//! against the arguments on every call, which is the whole reason `jni`
//! moved to it.
//!
//! **The classloader gotcha, found by testing (2026-09-05).** A class
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
//! `get_static_field` -- anything that resolves a *class*, as opposed to
//! `call_method` on an object it already has, which needs no such lookup)
//! defaults to `FindClass`'s ordinary search when it cannot find the
//! calling thread a classloader through `Thread.getContextClassLoader()`.
//! That default is fine on a thread the JVM itself started -- an
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
//! from `android-shell`'s own background thread (the notification
//! follow-loop, the share upload) is running on a thread *Rust* spawned
//! and attached with `JavaVM::attach_current_thread`, which the platform
//! never gave an app classloader. Framework classes
//! (`android.app.Notification$Builder`, ...) still resolve, because they
//! are reachable from the bootstrap loader `FindClass` falls back to --
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
//! packaged inside this app's own APK. The failure was
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
//! as "failed to resolve Java class ... (class not found or linkage
//! error)" -- on a real device this reads as "the notification silently
//! never arrives," since the whole call is inside the follow loop and the
//! ongoing foreground notification (built on the main thread, in
//! `try_start`, before the background thread exists) posts fine either
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
//! first time any entry point has a `Context` to ask, and every class
//! lookup below goes through it explicitly via `LoaderContext::Loader`
//! rather than the thread-dependent default -- so it is correct on the
//! main thread and on this crate's own background threads alike.
use jni::Env;
use jni::errors::Result;
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
use jni::refs::{Global, LoaderContext};
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
use jni::strings::JNIString;
use std::sync::OnceLock;
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
/// Caches `context`'s own `ClassLoader`, the first time this is called.
/// Cheap to call from every entry point that has a `Context` on hand
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
/// a `OnceLock::get` and nothing else.
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
if CLASS_LOADER.get().is_some() {
return Ok(());
}
// context.getClass().getClassLoader() -- resolved via `call_method` on
// real objects throughout, so this needs no class-name lookup of its
// own and has nothing to bootstrap.
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
let loader_obj = call_method(
env,
&class_obj,
"getClassLoader",
"()Ljava/lang/ClassLoader;",
&[],
)?
.l()?;
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
let global = env.new_global_ref(&loader)?;
// Lost the race with another entry point calling this concurrently --
// both loaders name the same app, so either one is fine and there is
// nothing to reconcile.
let _ = CLASS_LOADER.set(global);
Ok(())
}
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
/// through the cached app classloader when one has been remembered, and
/// through the ordinary default otherwise -- which is every call made
/// before any entry point has run, and is also correct for a main-thread
/// caller, so there is no case this makes worse.
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
match CLASS_LOADER.get() {
Some(loader) => {
let binary_name = name.replace('/', ".");
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
}
None => env.find_class(JNIString::new(name)),
}
}
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
resolve_class(env, name)
}
/// A new Java string as a plain `JObject` -- what every call site here
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
/// `JString: Into<JObject>` is the documented way across).
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
Ok(env.new_string(text)?.into())
}
pub fn new_object<'local>(
env: &mut Env<'local>,
class: &str,
sig: &str,
args: &[JValue],
) -> Result<JObject<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.new_object(class, sig.method_signature(), args)
}
pub fn call_method<'local>(
env: &mut Env<'local>,
obj: &JObject,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
}
pub fn call_static_method<'local>(
env: &mut Env<'local>,
class: &str,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
}
pub fn get_static_field<'local>(
env: &mut Env<'local>,
class: &str,
field: &str,
sig: &str,
) -> Result<JValueOwned<'local>> {
let sig = RuntimeFieldSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.get_static_field(class, JNIString::new(field), sig.field_signature())
}
+10
View File
@@ -0,0 +1,10 @@
android-project/.gradle/
android-project/build/
android-project/app/build/
# Rebuilt by `cargo ndk -o app/src/main/jniLibs/ build` before every
# Gradle build -- see RUST.md's I2 for the exact command.
android-project/app/src/main/jniLibs/
target/
Cargo.lock.orig
+187 -206
View File
@@ -166,6 +166,28 @@ dependencies = [
"memchr",
]
[[package]]
name = "ai-app"
version = "0.1.0"
dependencies = [
"android-view",
"android_logger",
"base64",
"event-model",
"iris",
"jni 0.22.4",
"libc",
"log",
"pulldown-cmark",
"serde",
"serde_json",
"tabs-ui",
"tempfile",
"tokio",
"ureq",
"winit",
]
[[package]]
name = "aligned"
version = "0.4.3"
@@ -566,18 +588,18 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-set"
version = "0.8.0"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
[[package]]
name = "bit_field"
@@ -606,12 +628,6 @@ dependencies = [
"no_std_io2",
]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block2"
version = "0.5.1"
@@ -621,6 +637,15 @@ dependencies = [
"objc2 0.5.2",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2 0.6.4",
]
[[package]]
name = "blocking"
version = "1.7.0"
@@ -740,16 +765,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"serde",
"serde_json",
"ureq",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -761,9 +776,9 @@ dependencies = [
[[package]]
name = "codespan-reporting"
version = "0.12.0"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
@@ -834,16 +849,6 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -857,8 +862,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"core-foundation",
"core-graphics-types",
"foreign-types",
"libc",
]
@@ -870,18 +875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.10.1",
"core-foundation",
"libc",
]
@@ -896,9 +890,9 @@ dependencies = [
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
@@ -906,18 +900,18 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.22"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crunchy"
@@ -1182,9 +1176,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.12.4"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23"
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
dependencies = [
"bytemuck",
]
@@ -1387,9 +1381,9 @@ dependencies = [
[[package]]
name = "glow"
version = "0.16.0"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
dependencies = [
"js-sys",
"slotmap",
@@ -1420,26 +1414,6 @@ dependencies = [
"windows",
]
[[package]]
name = "gpu-descriptor"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
dependencies = [
"bitflags 2.13.1",
"gpu-descriptor-types",
"hashbrown 0.15.5",
]
[[package]]
name = "gpu-descriptor-types"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
dependencies = [
"bitflags 2.13.1",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -1505,12 +1479,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hexf-parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "http"
version = "1.5.0"
@@ -1759,23 +1727,6 @@ dependencies = [
"winit",
]
[[package]]
name = "iris-android-app"
version = "0.1.0"
dependencies = [
"android-view",
"android_logger",
"client-core",
"event-model",
"iris",
"libc",
"log",
"serde_json",
"tabs-ui",
"tokio",
"transcript-ui",
]
[[package]]
name = "iris-core"
version = "0.1.0"
@@ -1785,6 +1736,7 @@ dependencies = [
"fxhash",
"image",
"parley",
"pollster",
"swash",
"wgpu",
]
@@ -1795,7 +1747,7 @@ version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.5",
]
[[package]]
@@ -1982,7 +1934,7 @@ dependencies = [
"bitflags 2.13.1",
"libc",
"plain",
"redox_syscall 0.9.3",
"redox_syscall 0.9.4",
]
[[package]]
@@ -2039,15 +1991,6 @@ dependencies = [
"imgref",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@@ -2082,21 +2025,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "metal"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15"
dependencies = [
"bitflags 2.13.1",
"block",
"core-graphics-types 0.2.0",
"foreign-types",
"log",
"objc",
"paste",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2129,9 +2057,9 @@ dependencies = [
[[package]]
name = "naga"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set",
@@ -2140,11 +2068,11 @@ dependencies = [
"cfg_aliases",
"codespan-reporting",
"half",
"hashbrown 0.16.1",
"hexf-parse",
"hashbrown 0.17.1",
"indexmap",
"libm",
"log",
"naga-types",
"num-traits",
"once_cell",
"rustc-hash",
@@ -2153,6 +2081,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "naga-types"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
dependencies = [
"hashbrown 0.17.1",
"indexmap",
"rustc-hash",
"thiserror 2.0.20",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2302,15 +2242,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -2343,13 +2274,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"libc",
"objc2 0.5.2",
"objc2-core-data",
"objc2-core-image",
"objc2-foundation 0.2.2",
"objc2-quartz-core",
"objc2-quartz-core 0.2.2",
]
[[package]]
@@ -2371,7 +2302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -2383,7 +2314,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2395,7 +2326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2430,10 +2361,10 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
"objc2-metal 0.2.2",
]
[[package]]
@@ -2442,7 +2373,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-contacts",
"objc2-foundation 0.2.2",
@@ -2471,7 +2402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"dispatch",
"libc",
"objc2 0.5.2",
@@ -2505,7 +2436,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -2518,11 +2449,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-metal"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.13.1",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-quartz-core"
version = "0.2.2"
@@ -2530,10 +2473,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
"objc2-metal 0.2.2",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.1",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"objc2-metal 0.3.2",
]
[[package]]
@@ -2553,7 +2510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-cloud-kit",
"objc2-core-data",
@@ -2561,7 +2518,7 @@ dependencies = [
"objc2-core-location",
"objc2-foundation 0.2.2",
"objc2-link-presentation",
"objc2-quartz-core",
"objc2-quartz-core 0.2.2",
"objc2-symbols",
"objc2-uniform-type-identifiers",
"objc2-user-notifications",
@@ -2573,7 +2530,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2585,7 +2542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -2857,9 +2814,9 @@ dependencies = [
[[package]]
name = "pollster"
version = "0.4.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
[[package]]
name = "portable-atomic"
@@ -3138,6 +3095,18 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "raw-window-metal"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rayon"
version = "1.12.0"
@@ -3195,9 +3164,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.9.3"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5"
checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
dependencies = [
"bitflags 2.13.1",
]
@@ -3309,9 +3278,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.43"
version = "0.23.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [
"log",
"once_cell",
@@ -3568,9 +3537,9 @@ dependencies = [
[[package]]
name = "spirv"
version = "0.3.0+sdk-1.3.268.0"
version = "0.4.0+sdk-1.4.341.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
dependencies = [
"bitflags 2.13.1",
]
@@ -3862,17 +3831,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "transcript-ui"
version = "0.1.0"
dependencies = [
"client-core",
"event-model",
"iris",
"log",
"pulldown-cmark",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
@@ -3943,9 +3901,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.0"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"
dependencies = [
"base64",
"cookie_store",
@@ -3963,9 +3921,9 @@ dependencies = [
[[package]]
name = "ureq-proto"
version = "0.6.1"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10"
dependencies = [
"base64",
"http",
@@ -4251,9 +4209,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wgpu"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9cb534d5ffd109c7d1135f34cdae29e60eab94855a625dcfe1705f8bc7ad79f"
checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
dependencies = [
"arrayvec",
"bitflags 2.13.1",
@@ -4261,7 +4219,7 @@ dependencies = [
"cfg-if",
"cfg_aliases",
"document-features",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
"js-sys",
"log",
"naga",
@@ -4281,9 +4239,9 @@ dependencies = [
[[package]]
name = "wgpu-core"
version = "28.0.1"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d23f4642f53f666adcfd2d3218ab174d1e6681101aef18696b90cbe64d1c10f9"
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
dependencies = [
"arrayvec",
"bit-set",
@@ -4292,10 +4250,11 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"document-features",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
"indexmap",
"log",
"naga",
"naga-types",
"once_cell",
"parking_lot",
"portable-atomic",
@@ -4308,66 +4267,70 @@ dependencies = [
"wgpu-core-deps-emscripten",
"wgpu-core-deps-windows-linux-android",
"wgpu-hal",
"wgpu-naga-bridge",
"wgpu-types",
]
[[package]]
name = "wgpu-core-deps-apple"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87b7b696b918f337c486bf93142454080a32a37832ba8a31e4f48221890047da"
checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-emscripten"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b251c331f84feac147de3c4aa3aa45112622a95dd7ee1b74384fa0458dbd79"
checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-windows-linux-android"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ca976e72b2c9964eb243e281f6ce7f14a514e409920920dcda12ae40febaae"
checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-hal"
version = "28.0.1"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d6cb474beb218824dcc9e1ce679d973f719262789bfb27407da560cac20eeb"
checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
dependencies = [
"android_system_properties",
"arrayvec",
"ash",
"bit-set",
"bitflags 2.13.1",
"block",
"block2 0.6.2",
"bytemuck",
"cfg-if",
"cfg_aliases",
"core-graphics-types 0.2.0",
"glow",
"glutin_wgl_sys",
"gpu-allocator",
"gpu-descriptor",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
"js-sys",
"khronos-egl",
"libc",
"libloading",
"log",
"metal",
"naga",
"naga-types",
"ndk-sys",
"objc",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"objc2-metal 0.3.2",
"objc2-quartz-core 0.3.2",
"once_cell",
"ordered-float",
"parking_lot",
@@ -4376,26 +4339,44 @@ dependencies = [
"profiling",
"range-alloc",
"raw-window-handle",
"raw-window-metal",
"renderdoc-sys",
"smallvec",
"static_assertions",
"thiserror 2.0.20",
"wasm-bindgen",
"wayland-sys",
"web-sys",
"wgpu-naga-bridge",
"wgpu-types",
"windows",
"windows-core",
"windows-result",
]
[[package]]
name = "wgpu-naga-bridge"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081"
dependencies = [
"naga",
"wgpu-types",
]
[[package]]
name = "wgpu-types"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c"
checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
dependencies = [
"bitflags 2.13.1",
"bytemuck",
"js-sys",
"log",
"naga-types",
"raw-window-handle",
"static_assertions",
"web-sys",
]
@@ -4759,12 +4740,12 @@ dependencies = [
"android-activity",
"atomic-waker",
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"bytemuck",
"calloop",
"cfg_aliases",
"concurrent-queue",
"core-foundation 0.9.4",
"core-foundation",
"core-graphics",
"cursor-icon",
"dpi",
@@ -5063,18 +5044,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
[[package]]
name = "zerocopy"
version = "0.8.56"
version = "0.8.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
version = "0.8.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
dependencies = [
"proc-macro2",
"quote",
+109
View File
@@ -0,0 +1,109 @@
# Product code lives here; reusable UI belongs in `iris/`.
[package]
name = "ai-app"
version = "0.1.0"
edition = "2024"
# Android loads the cdylib; desktop, examples, and tests link the rlib.
[lib]
name = "ai_app"
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "ai-app-desktop"
path = "src/bin_desktop.rs"
required-features = ["screens"]
[[example]]
name = "transcript"
required-features = ["screens"]
[[example]]
name = "phone"
required-features = ["fixture"]
[dependencies]
event-model = { path = "../event-model" }
serde = { version = "1", features = ["derive"] }
# Transcript lines must retain exact float values and raw JSON bytes.
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
ureq = { version = "3", features = ["json"] }
pulldown-cmark = "0.13.4"
base64 = "0.23"
log = { version = "0.4.34", features = ["std"] }
# Optional so the Compose shell does not link the renderer.
iris = { path = "../iris", optional = true }
tabs-ui = { path = "../iris/tabs-ui", optional = true }
jni = { version = "0.22", optional = true }
libc = { version = "0.2.189", optional = true }
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
[target.'cfg(not(target_os = "android"))'.dependencies]
winit = "0.30.13"
# Keep this pin synchronized with `iris/Cargo.toml`.
[target.'cfg(target_os = "android")'.dependencies]
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
android_logger = "0.15.1"
[features]
default = ["screens", "fixture"]
screens = ["dep:iris"]
# Default-on for tests; APK builds opt in so ordinary APKs omit the 1.9 MB fixture.
fixture = ["screens"]
transcript-screen = ["screens"]
tabs-screen = ["screens", "dep:tabs-ui"]
bench = ["transcript-screen", "fixture", "dep:libc", "dep:tokio"]
shell = ["dep:jni"]
force-gles = ["screens", "iris/force-gles"]
[dev-dependencies]
tempfile = "3"
tokio = { version = "1.53.1", features = ["rt", "time"] }
# APK builds select these profiles explicitly.
[profile.android-release]
inherits = "release"
panic = "abort"
strip = true
lto = "fat"
codegen-units = 1
# A warm-fling profile measured p90/p99 0.09/0.26 ms at 3 versus
# 0.15/0.42 ms at "s"; the 1.9 MB saving is not worth that frame cost.
opt-level = 3
[profile.android-dev]
inherits = "dev"
panic = "abort"
# Full DWARF in each renderer-linked test binary writes tens of gigabytes.
[profile.dev]
debug = "line-tables-only"
[profile.test]
debug = "line-tables-only"
[[test]]
name = "catch_a_fling"
required-features = ["fixture"]
[[test]]
name = "fence_fling"
required-features = ["fixture"]
[[test]]
name = "gesture_cancel"
required-features = ["fixture"]
[[test]]
name = "input_log_roundtrip"
required-features = ["fixture"]
[[test]]
name = "phone_screen"
required-features = ["fixture"]
[[test]]
name = "top_edge"
required-features = ["fixture"]
@@ -13,8 +13,37 @@ android {
defaultConfig {
applicationId = "dev.iris.android.demo"
minSdk = 26
targetSdk = 34
// 29, not 26: `iris::android::view`'s touch handler dates each
// sample with `MotionEvent.getEventTimeNanos` and
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
// method there is a hard crash on the first touch rather than a
// degraded fling. Raised deliberately rather than guarded at
// runtime: nothing this app is built for runs below 29, and an
// untested fallback path is its own defect. `build-apk.sh`'s
// `cargo ndk -P` is kept at the same number.
minSdk = 29
// 37, matching `compileSdk` and the Compose app in `app/` -- which
// is the one part of this that is measured rather than reasoned:
// that app targets 37 and its keyboard does push the transcript up
// on Iris's phone, and this one targeted 34 and does not
// (2026-09-07). The emulator here is API 36 and the push-up works
// there at either target, so the target is the only difference the
// two devices do not share.
//
// The mechanism, stated as the reading it is: below targetSdk 35
// a window keeps the legacy behaviour, where `adjustResize` shrinks
// the window for the IME and `getInsets(ime()).bottom` therefore
// measures the overlap with an already-shrunk window -- zero, with
// nothing left to push up. `MainActivity`'s
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
// 36 it still takes; Android 16 deprecated it and Android 17 is
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
// the app is handed the real overlap without relying on a
// deprecated call. If the phone still reports `ime_bottom=0` with
// a nonzero `dispatches` in the Diagnostics pane, this reading was
// wrong and the `WindowInsetsAnimation.Callback` in
// `MainActivity` is the other half to look at.
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Only needed by the transcript-screen feature (RUST.md's I5),
which talks to a real ai-server; the plain tabs demo (I2/I4) makes
no network call and never noticed this was missing. Absent,
UreqTransport::new's connect failed with EPERM (Operation not
permitted), not the ECONNREFUSED/ENETUNREACH a firewall or a dead
server would give: a seccomp-level socket denial reads nothing
like a network problem, which is what made it worth a comment. -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="iris android-view demo"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- The enrollment link Dev Updater's Enroll button opens
(what `ai-server` mints), the same one the Compose app
in `app/` registers: which app answers it is the phone
owner's choice at the moment of the tap, and both being
offered is the intended behaviour rather than a clash.
BROWSABLE so a link tapped in another app reaches here,
and `android:host` so this app is not offered for every
aiapp:// URI a future route invents. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="aiapp" android:host="enroll" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="ai_app" />
</activity>
<!-- This app's own recent log, for Dev Updater to read on the
phone. Iris runs these builds with no adb, and Android
forbids one app reading another's logcat, so this is the
only way a log::info! here reaches her. The shape is Dev
Updater's contract (its README.md, "An app's own log"), not
something invented for this app.
The authority carries ${applicationId}, so the bench package
and the ordinary one each get their own and neither can read
the other's log. Exported, because the whole point is
another app reading it, and guarded by a permission Dev
Updater declares at protectionLevel="normal" (a signature
permission is not available: the two apps are signed with
different locally generated keys). Read-only: insert,
update and delete throw. -->
<provider
android:name=".DevLogProvider"
android:authorities="${applicationId}.devlog"
android:exported="true"
android:readPermission="dev.updater.permission.READ_DEVLOG" />
</application>
</manifest>
@@ -0,0 +1,125 @@
package dev.iris.android.demo;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
/** Read-only Dev Updater log provider; its URI and column schema are an external contract. */
public final class DevLogProvider extends ContentProvider {
static {
// A provider can start the process without creating MainActivity.
System.loadLibrary("ai_app");
}
private static final int FIELDS_PER_LINE = 5;
private static final String[] LINE_COLUMNS = {"seq", "t_ms", "level", "target", "message"};
private static final String[] STATUS_COLUMNS = {"held", "dropped", "newest_seq"};
private static final int LINES = 1;
private static final int STATUS = 2;
private UriMatcher matcher;
private static native String[] nativeLinesSince(long since);
private static native String[] nativeStatus();
// The provider may be the process's only component, so it must supply
// the files directory normally initialized by MainActivity.
private static native void nativeReady(String authority, String filesDir);
@Override
public boolean onCreate() {
String authority = getContext().getPackageName() + ".devlog";
matcher = new UriMatcher(UriMatcher.NO_MATCH);
matcher.addURI(authority, "lines", LINES);
matcher.addURI(authority, "status", STATUS);
nativeReady(authority, getContext().getFilesDir().getAbsolutePath());
return true;
}
@Override
public Cursor query(
Uri uri,
String[] projection,
String selection,
String[] selectionArgs,
String sortOrder) {
switch (matcher.match(uri)) {
case LINES:
return lines(sinceOf(uri));
case STATUS:
return status();
default:
return null;
}
}
private static long sinceOf(Uri uri) {
String since = uri.getQueryParameter("since");
if (since == null) {
return 0;
}
try {
return Long.parseLong(since);
} catch (NumberFormatException ignored) {
return 0;
}
}
private static Cursor lines(long since) {
String[] fields = nativeLinesSince(since);
if (fields == null) {
return null;
}
MatrixCursor cursor = new MatrixCursor(LINE_COLUMNS, fields.length / FIELDS_PER_LINE);
for (int at = 0; at + FIELDS_PER_LINE <= fields.length; at += FIELDS_PER_LINE) {
cursor.addRow(
new Object[] {
Long.parseLong(fields[at]),
Long.parseLong(fields[at + 1]),
fields[at + 2],
fields[at + 3],
fields[at + 4],
});
}
return cursor;
}
private static Cursor status() {
String[] fields = nativeStatus();
if (fields == null || fields.length != STATUS_COLUMNS.length) {
return null;
}
MatrixCursor cursor = new MatrixCursor(STATUS_COLUMNS, 1);
cursor.addRow(
new Object[] {
Long.parseLong(fields[0]), Long.parseLong(fields[1]), Long.parseLong(fields[2]),
});
return cursor;
}
@Override
public String getType(Uri uri) {
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("this app's log is read-only");
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("this app's log is read-only");
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("this app's log is read-only");
}
}
@@ -0,0 +1,60 @@
package dev.iris.android.demo;
import android.app.Activity;
import android.content.Context;
import android.view.Gravity;
import android.widget.ScrollView;
import android.widget.TextView;
import org.linebender.android.rustview.RustView;
/**
* android-view's abstract base plus the two native methods it has no hook
* for: window insets and unregistering this view's entry in
* iris::android::insets's side table. See iris/src/android/insets.rs's doc
* comment for why those could not ride along on an existing android-view
* callback the way the back gesture does.
*/
public final class IrisView extends RustView {
@Override
protected native long newViewPeer(Context context);
native void applyWindowInsetsNative(
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
native void unregisterInsetsNative(long peer);
public IrisView(Context context) {
super(context);
}
void applyWindowInsets(
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
}
@Override
protected void onDetachedFromWindow() {
unregisterInsetsNative(mViewPeer);
super.onDetachedFromWindow();
}
// This path must not depend on the renderer that failed to initialize.
void showRendererError(String report) {
Context context = getContext();
if (!(context instanceof Activity)) {
return;
}
Activity activity = (Activity) context;
TextView text = new TextView(activity);
text.setText(report);
text.setTextIsSelectable(true);
text.setGravity(Gravity.TOP | Gravity.START);
int pad = (int) (16 * activity.getResources().getDisplayMetrics().density);
text.setPadding(pad, pad, pad, pad);
ScrollView scroll = new ScrollView(activity);
scroll.addView(text);
activity.setContentView(scroll);
}
}
@@ -0,0 +1,104 @@
package dev.iris.android.demo;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.WindowInsets;
import android.view.WindowInsetsAnimation;
import android.widget.FrameLayout;
import java.util.List;
public final class MainActivity extends Activity {
static {
System.loadLibrary("ai_app");
}
private static native void nativeSetFilesDir(String path);
private static native void nativeEnroll(String uri);
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
nativeSetFilesDir(getFilesDir().getAbsolutePath());
handleEnrollmentIntent(getIntent());
IrisView view = new IrisView(this);
view.setLayoutParams(new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
view.setFocusable(true);
view.setFocusableInTouchMode(true);
FrameLayout layout = new FrameLayout(this);
layout.addView(view);
setContentView(layout);
view.requestFocus();
// Edge-to-edge makes IME-only changes produce fresh inset dispatches.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getWindow().setDecorFitsSystemWindows(false);
}
// Static dispatch supplies settled insets; the animation callback
// supplies intermediate IME heights. An interrupted animation may
// omit its final progress frame, so onEnd re-reads the root insets.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
@Override
public WindowInsets onProgress(
WindowInsets insets, List<WindowInsetsAnimation> running) {
sendInsets(view, insets);
return insets;
}
@Override
public void onEnd(WindowInsetsAnimation animation) {
WindowInsets settled = view.getRootWindowInsets();
if (settled != null) {
sendInsets(view, settled);
}
}
});
}
view.setOnApplyWindowInsetsListener((v, insets) -> {
sendInsets((IrisView) v, insets);
return insets;
});
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
// Keep getIntent() consistent with the enrollment being handled.
setIntent(intent);
handleEnrollmentIntent(intent);
}
private static void handleEnrollmentIntent(Intent intent) {
if (intent == null) {
return;
}
Uri data = intent.getData();
if (data != null) {
nativeEnroll(data.toString());
}
}
private static void sendInsets(IrisView view, WindowInsets insets) {
int left = insets.getSystemWindowInsetLeft();
int top = insets.getSystemWindowInsetTop();
int right = insets.getSystemWindowInsetRight();
int bottom = insets.getSystemWindowInsetBottom();
// Visibility and height disagree during IME animation, so neither
// can be inferred from the other.
int imeBottom = 0;
int imeVisible = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
}
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
}
}
@@ -16,12 +16,8 @@ import android.view.inputmethod.InputMethodManager;
public abstract class RustView extends SurfaceView
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view)
// with one deliberate change: `protected` rather than package-private, so a
// subclass in a different package (dev.iris.android.demo.IrisView) can pass
// it to the window-insets native call android-view itself has no hook for --
// see iris/src/android/insets.rs's doc comment for why that call exists at
// all. No other line differs from upstream.
// Vendored from android-view bec6c62. The only local change is `protected`,
// allowing IrisView to forward insets through this native peer.
protected final long mViewPeer;
final InputMethodManager mInputMethodManager;
File renamed without changes.
File renamed without changes.
+120
View File
@@ -0,0 +1,120 @@
#!/bin/sh
# Builds the Android app end to end: the cdylib (cargo ndk from this
# directory, straight into android-project/app/src/main/jniLibs/) then the
# APK (Gradle, from android-project/). Written to stop re-typing
# the same incantation by hand every time (ANDROID_HOME/NDK exports, the
# cargo ndk invocation, the keystore env for a release build, apksigner/
# aapt2 verification) -- see docs/RUST.md's P0 box. Same shape as `app/
# build-apk.sh` (the Compose app's own build script) and `app/
# iris-scroll.sh` (no coordinates, set -eu, exit 0 on success).
#
# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"]
# debug/release default to debug (matches this-machine-android's "the
# emulator stays on debug" rule -- pass `release` explicitly for a phone
# build). --abi defaults to arm64-v8a (a phone/real device); pass
# x86_64 for this checkout's own AVD. --features defaults to
# "transcript-screen bench" -- deliberately *without* `force-gles`, and
# nothing should add it back for the emulator's sake.
#
# **The emulator does not need a GLES build, because it has no hardware
# Vulkan to be steered away from** (docs/RUST.md, "What the emulator
# gives a GPU app", 2026-09-08): its guest's only Vulkan is SwiftShader
# in software, its GLES is the host's real GPU through virgl, and iris's
# own runtime fallback -- `Backends::PRIMARY`, no adapter, rebuild on
# `Backends::GL` -- takes an ordinary build there by itself. So the
# emulator and the phone run the *same binary* and differ only in what
# that binary finds, which is the whole point: a build flag that changed
# the backend would mean the thing measured here is not the thing
# shipped.
#
# `force-gles` (`iris/Cargo.toml`'s own doc) pins the backend at compile
# time for a backend-isolation measurement (RUST.md's I5, "Where iris's
# frame time goes"), and the desktop is the better place to run it now
# (`run-headless.sh ... --features iris/force-gles`). It was never meant
# to reach a real device, but this script's old default put it in every
# arm64 build regardless, so the P0 bench APK delivered to Iris's phone
# forced GLES there too -- the named hypothesis in RUST.md's P0 box
# ("iris bench crash on the phone, 2026-09-06"). Never pass it for a
# build meant for a phone.
set -eu
cd "$(dirname "$0")"
BUILD_TYPE="debug"
ABI="arm64-v8a"
FEATURES="transcript-screen bench"
case "${1:-}" in
debug|release) BUILD_TYPE="$1"; shift ;;
esac
while [ $# -gt 0 ]; do
case "$1" in
--abi) ABI="$2"; shift 2 ;;
--features) FEATURES="$2"; shift 2 ;;
*) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;;
esac
done
SDK_ROOT="$HOME/Android/Sdk"
export ANDROID_HOME="$SDK_ROOT"
export ANDROID_SDK_ROOT="$SDK_ROOT"
NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1)
if [ -z "$NDK_DIR" ]; then
echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2
exit 1
fi
export ANDROID_NDK_HOME="$NDK_DIR"
# Only the ABI asked for goes into the APK. cargo ndk adds its output beside
# whatever earlier builds left here, and Gradle packages every directory it
# finds -- a debug x86_64 emulator build left behind made an arm64 "release"
# 339 MB on 2026-09-06.
rm -rf android-project/app/src/main/jniLibs
# ...and Gradle's own copy of them, which `rm -rf jniLibs` does not reach.
# `mergeReleaseNativeLibs` is *up to date* against its cached inputs, so a
# build that switches ABI packages the previous ABI: an `--abi x86_64`
# release APK containing `lib/arm64-v8a/libmain.so` installed fine and
# aborted at startup with `Could not get adapter!: NotFound {
# active_backends: VULKAN }` under libndk_translation -- which reads
# exactly like the phone's own Vulkan problem and is nothing of the kind.
# Scoped to the merge task's directory rather than all of `app/build`, so
# an ABI change costs the native merge and not the whole Gradle build.
rm -rf android-project/app/build/intermediates/merged_native_libs \
android-project/app/build/intermediates/stripped_native_libs \
android-project/app/build/intermediates/merged_jni_libs
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
if [ "$BUILD_TYPE" = "release" ]; then
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
--profile android-release --no-default-features --features "$FEATURES"
else
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
--profile android-dev --no-default-features --features "$FEATURES"
fi
GRADLE_TASK="assembleDebug"
APK_DIR="android-project/app/build/outputs/apk/debug"
APK_NAME="app-debug.apk"
if [ "$BUILD_TYPE" = "release" ]; then
GRADLE_TASK="assembleRelease"
APK_DIR="android-project/app/build/outputs/apk/release"
APK_NAME="app-release.apk"
# Same key `app/build-apk.sh` (the Compose app) generates once under
# ~/.config/ai-app/release.jks -- see AGENTS.md's "Checking your work".
export AI_APP_KEYSTORE="$HOME/.config/ai-app/release.jks"
if [ ! -f "$AI_APP_KEYSTORE" ]; then
echo "build-apk.sh: no release key at $AI_APP_KEYSTORE -- run app/build-apk.sh once first" >&2
exit 1
fi
export AI_APP_KEYSTORE_PASSWORD
AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password")
fi
(cd android-project && gradle ":app:$GRADLE_TASK" --console=plain)
APK_PATH="$(pwd)/$APK_DIR/$APK_NAME"
BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1)
echo "--- aapt2 dump badging ---"
"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5
if [ "$BUILD_TYPE" = "release" ]; then
echo "--- apksigner verify ---"
"${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH"
fi
echo "$APK_PATH"
+102
View File
@@ -0,0 +1,102 @@
use iris::prelude::*;
use winit::{dpi::PhysicalSize, window::WindowAttributes};
fn ime_argv() -> Option<f32> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--ime" {
return args.next()?.parse().ok();
}
}
None
}
fn message_argv() -> Option<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--message" {
return Some(args.next()?.replace("\\n", "\n"));
}
}
None
}
fn typed_argv() -> Option<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--typed" {
return Some(args.next()?.replace("\\n", "\n"));
}
}
None
}
fn main() {
DefaultApp::<Client>::run();
}
#[derive(DefaultUiState)]
pub struct Client {
ui_state: DefaultUiState,
#[allow(dead_code)]
screen: Option<ai_app::ui::TranscriptScreen>,
}
impl DefaultAppState for Client {
fn window_attributes() -> WindowAttributes {
WindowAttributes::default()
.with_title("iris transcript (bench fixture)")
.with_inner_size(PhysicalSize::new(
ai_app::ui::fixture::PHONE_WIDTH,
ai_app::ui::fixture::PHONE_HEIGHT,
))
}
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let screen = match ai_app::ui::fixture::open(rsc, &mut ui_state) {
Ok(opened) => {
if let Some(message) = message_argv() {
opened.screen.composer.field.edit(rsc).set(&message);
}
if let Some(text) = typed_argv() {
let field = opened.screen.composer.field;
let redraw = rsc.tasks.redraw_handle();
rsc.spawn_task(async move |mut ctx| {
for ch in text.chars() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
ctx.update(move |state: &mut Client, rsc| {
state.set_focus(Some(field));
let end = rsc[field].text().len();
let mut edit = field.edit(rsc);
if edit.text.caret().is_none() {
edit.set_cursor_byte(end);
}
edit.insert(&ch.to_string());
});
redraw.request_redraw();
}
});
}
if let Some(inset) = ime_argv() {
opened.screen.composer.set_bottom_inset(rsc, inset);
}
Some(opened.screen)
}
Err(message) => {
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
.color(PaintId::WHITE)
.wrap(true)
.pad(dp(16))
.add_strong(rsc)
.any();
ui_state.set_root(rsc, text);
None
}
};
Self { ui_state, screen }
}
}
+208
View File
@@ -0,0 +1,208 @@
use ai_app::client::QuestionOption;
use ai_app::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use iris::prelude::*;
fn main() {
DefaultApp::<Client>::run();
}
#[derive(DefaultUiState)]
pub struct Client {
ui_state: DefaultUiState,
#[allow(dead_code)]
screen: ai_app::ui::TranscriptScreen,
}
fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
FoldedRow::Single(if from_user {
TranscriptItem::UserMsg {
seq,
text: text.to_string(),
attachments: Vec::new(),
}
} else {
TranscriptItem::AssistantMsg {
seq,
text: text.to_string(),
settled: true,
}
})
}
fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem {
tool_call_in("run1", id, tool, input, result)
}
fn tool_call_in(
run: &str,
id: &str,
tool: &str,
input: &str,
result: Option<(&str, bool)>,
) -> TranscriptItem {
TranscriptItem::ToolRun {
seq: 3,
id: id.into(),
run_id: run.into(),
tool: tool.into(),
input: input.into(),
output: result.map(|(out, _)| out.to_string()).unwrap_or_default(),
done: result.is_some(),
failed: result.is_some_and(|(_, failed)| failed),
asks: Vec::new(),
images: Vec::new(),
}
}
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
let mut call = tool_call_in("run2", id, tool, input, None);
if let TranscriptItem::ToolRun { asks, .. } = &mut call {
asks.push(QuestionCard {
seq: 9,
id: format!("{id}-q"),
prompt: "Allow this command?".into(),
header: None,
options: vec![
QuestionOption {
label: "Allow".into(),
description: None,
preview: None,
},
QuestionOption {
label: "Deny".into(),
description: None,
preview: None,
},
],
multi_select: false,
answers: Vec::new(),
});
}
call
}
fn long_output() -> String {
(0..200)
.map(|i| format!("test ai_app::ui::case_{i} ... ok"))
.collect::<Vec<_>>()
.join("\n")
}
fn synthetic_rows() -> Vec<FoldedRow> {
vec![
msg(
1,
true,
"Can you show me a **bold** word, some *italic* text, and `inline code`?",
),
msg(
2,
false,
"# Sure\n\nHere's a [link to the repo](https://example.com/ai-app-2) and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
),
FoldedRow::Tools(vec![
tool_call(
"t1",
"Read",
r#"{"file_path": "src/main.rs"}"#,
Some(("fn main() {}\n", false)),
),
tool_call(
"t2",
"Bash",
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
Some((
"error: could not compile `iris`\nCaused by: linker not found",
true,
)),
),
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
]),
FoldedRow::Single(tool_call(
"t5",
"Bash",
r#"{"command": "cargo test -p transcript-ui -- --nocapture"}"#,
Some((&long_output(), false)),
)),
msg(6, true, "Looks good, thanks!"),
msg(7, false, BLOCK_SAMPLER),
]
}
const BLOCK_SAMPLER: &str = "\
## What changed
Iris **fold** render measure session window anchor context transcript \
iris measure iris scroll call transcript layout *cursor* context, and a \
[bench](https://example.com/bench) link.
```rust
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
let mut out = items;
out.push(Item::new(seq));
out
}
```
| column | value |
|---|---|
| a | measure place draw tool call token context window anchor |
- one bullet
- another, with `inline code`
- nested one level
1. first numbered
2. second numbered
> A quoted line, to show the bar and the indent.
";
impl DefaultAppState for Client {
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows());
screen.push_row(
rsc,
&FoldedRow::Single(TranscriptItem::CommandRow {
seq: 8,
text: "clear".into(),
}),
);
screen.push_row(
rsc,
&FoldedRow::Tools(vec![
tool_call_in(
"run2",
"t6",
"Read",
r#"{"file_path": "docs/RUST.md"}"#,
Some(("# Moving the app to Rust\n", false)),
),
tool_call_in(
"run2",
"t7",
"Bash",
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
Some(("error: unused variable `x`", true)),
),
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
asking(
"t9",
"Bash",
r#"{"command": "rm -rf target", "timeout": 120000, "description": "Clear the build"}"#,
),
]),
);
screen.set_session_working(rsc, true);
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
assert!(
screen.expand_tail_tools(rsc, true),
"the newest row must be the tool run this flag is about"
);
}
Self { ui_state, screen }
}
}
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
# Installs and runs the iris `bench` build on this checkout's own emulator
# (per this-machine-android's per-checkout-AVD rule; `emu serial` picks it)
# and prints the report -- the iris half of `app/transcript-bench.sh`'s
# job. No coordinates: the button is found by its accessibility label
# through `ui-trace`, per AGENTS.md's "Driving the UI".
#
# Usage: ./run-bench.sh [--apk PATH]
# Defaults to this checkout's own release APK
# (android-project/app/build/outputs/apk/release/app-release.apk) if it
# exists, else the
# debug one -- build one first with ./build-apk.sh.
set -eu
cd "$(dirname "$0")"
APK=""
while [ $# -gt 0 ]; do
case "$1" in
--apk) APK="$2"; shift 2 ;;
*) echo "run-bench.sh: unknown argument: $1" >&2; exit 1 ;;
esac
done
if [ -z "$APK" ]; then
if [ -f android-project/app/build/outputs/apk/release/app-release.apk ]; then
APK=android-project/app/build/outputs/apk/release/app-release.apk
else
APK=android-project/app/build/outputs/apk/debug/app-debug.apk
fi
fi
if [ ! -f "$APK" ]; then
echo "run-bench.sh: no APK at $APK -- run ./build-apk.sh first" >&2
exit 1
fi
SERIAL=$(emu serial)
PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
if [ -z "$PKG" ]; then
BUILD_TOOLS=$(ls -d "$HOME"/Android/Sdk/build-tools/*/ | sort -V | tail -1)
PKG=$("${BUILD_TOOLS}aapt2" dump badging "$APK" | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
fi
echo "run-bench.sh: installing $APK ($PKG) on $SERIAL"
adb -s "$SERIAL" install -r "$APK" >/dev/null
adb -s "$SERIAL" shell am force-stop "$PKG"
adb -s "$SERIAL" logcat -c
adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/dev/null
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
# Which adapter drew, before any number is printed. The emulator is a GLES
# machine -- its guest has no hardware Vulkan (docs/RUST.md, "What the
# emulator gives a GPU app") -- so iris's runtime fallback lands on `Gl`,
# and `Gl (... virgl ...)` is the host's real GPU while `Gl (...
# SwiftShader ...)` is the CPU. Those two produce frame times an order of
# magnitude apart and are otherwise indistinguishable in this report, so
# the line is printed rather than left in logcat for somebody to think of.
ADAPTER=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null \
| sed -n 's/.*\(iris renderer: .*\)/\1/p' | tail -1)
if [ -n "$ADAPTER" ]; then
echo "run-bench.sh: $ADAPTER"
else
echo "run-bench.sh: no 'iris renderer:' line in logcat -- cannot say what drew this run" >&2
fi
# Poll for the report line rather than a fixed sleep -- the run itself is
# a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
# a longer script than v1's swipe-loop-only run.
# The report's own first line, not the bare "iris bench report:" prefix:
# `copy_report` logs that prefix too ("nothing to copy -- run the benchmark
# first", which the app emits at startup), so polling for the prefix
# returned instantly and the script printed a report that was never run.
REPORT_LINE="iris bench report: iris bench report"
i=0
while [ "$i" -lt 260 ]; do
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
if [ -n "$LINE" ]; then
break
fi
i=$((i + 1))
sleep 1
done
if [ -z "$LINE" ]; then
echo "run-bench.sh: no report after 260s -- check logcat by hand" >&2
exit 1
fi
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
# phases, four lines each) on top of the frames/bench sections v1 had.
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "$REPORT_LINE"
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "nightly"
components = ["clippy", "rustfmt"]
targets = ["aarch64-linux-android", "x86_64-linux-android"]
+135
View File
@@ -0,0 +1,135 @@
//! The platform half of this app's logging: what
//! `crate::client::log_ring` needs that only Android can supply, which is
//! `android_logger` as the logger to forward to and nothing else.
use crate::client::log_ring::{self, LogRing};
/// Installs the ring in front of `android_logger`, so `logcat` still sees
/// exactly what it saw before and the ring sees it too.
///
/// Called once, from `JNI_OnLoad`. A second call is refused by `log`
/// itself; the message says which caller, since two initialisation paths
/// is a programmer error rather than something to recover from.
pub fn install(max_level: log::LevelFilter) {
let inner = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(max_level)
.with_tag("iris-android-app"),
);
if log_ring::install_process_logger(
Box::new(inner),
max_level,
iris::diagnostics::trace_enabled,
)
.is_err()
{
// Not a panic: a logger already installed means logging works,
// just without the ring, and taking the app down over a
// diagnostic would be worse than the diagnostic being missing.
// The line goes through whatever logger did win.
log::warn!("iris app log: a logger was already installed, so there is no ring");
}
install_panic_hook();
}
pub fn ring() -> &'static LogRing {
log_ring::process_ring()
}
#[cfg(feature = "bench")]
pub fn diagnostics_line() -> String {
let where_to_read = match crate::android::devlog::authority() {
Some(authority) => format!("devlog provider: content://{authority}"),
// Not "off": Android creates a provider lazily, so this is what
// "nobody has asked for it yet" looks like, and it is a different
// thing from a build that does not have one.
None => "devlog provider: declared, not created yet".to_string(),
};
format!("{}\n{where_to_read}", ring().summary())
}
/// Where the panic hook leaves its report, under the app's private
/// directory. Read back and dropped by [`set_crash_dir`] on the next
/// start.
const CRASH_FILE: &str = "last-panic.txt";
/// How many of the dying run's own log lines the panic hook saves with
/// the panic, and [`set_crash_dir`] replays.
///
/// The panic's message and location say *what* broke; these say what the
/// app was doing on the way there, which is the half that is otherwise
/// unrecoverable -- the ring is memory only, so an abort takes every line
/// before the panic with it. Bounded rather than the whole ring because
/// this is written by a hook on a process that is about to die, and
/// because the replay pushes each line into the new run's ring, where an
/// unbounded paste would evict the run that is actually being watched.
const CRASH_CONTEXT_LINES: usize = 80;
const PREVIOUS_RUN_TARGET: &str = "previous_run";
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// Copies aborting panics into the device-readable log ring.
fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let where_at = match info.location() {
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
None => "an unknown location".to_string(),
};
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
let line = format!("iris panic at {where_at}: {message}");
log::error!("{line}");
if let Some(path) = CRASH_PATH.get() {
// The panic line first, then what the app was doing before
// it: one file, split again on that first newline by
// `set_crash_dir`.
let context = ring()
.try_tail_text(CRASH_CONTEXT_LINES)
.unwrap_or_else(|| {
"(the log ring was locked as this run died; no context)".to_string()
});
// Best effort by design: a panic is already the failure, and
// failing to record it must not become a second one.
let _ = std::fs::write(path, format!("{line}\n{context}"));
}
previous(info);
}));
}
/// Tells the panic hook where to leave its report, and replays the report
/// a previous run left there into the ring before deleting it.
///
/// Called from **both** `MainActivity.nativeSetFilesDir` and
/// `DevLogProvider.nativeReady` -- whichever of the two runs first in
/// this process, since after a crash Dev Updater's query starts the
/// process for the provider alone and no activity ever runs. Safe to call
/// twice: the file is gone after the first, so the second finds nothing
/// and says nothing. The panic itself is replayed at `error` level and
/// says it is from the previous run, so a crash loop shows the reason it
/// is looping in the Runtime tab of the run that is still up.
pub fn set_crash_dir(dir: &std::path::Path) {
let path = dir.join(CRASH_FILE);
if let Ok(previous) = std::fs::read_to_string(&path) {
// Delete before replaying rather than after: a replay that itself
// panicked would otherwise leave the file to be replayed again on
// every start, and a crash loop nothing can get out of is worse
// than one report lost.
let _ = std::fs::remove_file(&path);
replay_crash(&previous);
}
let _ = CRASH_PATH.set(path);
}
/// Puts a previous run's report back in the ring: its context lines in
/// the order they happened, then the panic itself.
fn replay_crash(report: &str) {
let (panic_line, context) = report.split_once('\n').unwrap_or((report, ""));
for line in context.lines().filter(|line| !line.is_empty()) {
ring().push(log::Level::Info, PREVIOUS_RUN_TARGET, line.to_string());
}
log::error!(
"iris app log: the previous run died -- {}",
panic_line.trim()
);
}
+881
View File
@@ -0,0 +1,881 @@
use crate::android::bench_jni::PlatformHandle;
use crate::client::transcript_fold::{TranscriptItem, fold_event};
use android_view::jni::{JavaVM, objects::GlobalRef};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const STREAM_EVENTS_PER_SEC: u64 = 20;
const STREAM_SECONDS: u64 = 20;
const LEGACY_CYCLES: usize = 6;
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
const FLING_COUNT: usize = 8;
const FLING_SETTLE_CAP_MS: u64 = 3_000;
const FLING_PAUSE_MS: u64 = 300;
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, \
telecommunications, and interdisciplinary collaboration all push a narrow composer field to \
wrap across several lines while the transcript above is pushed upward by the growing \
keyboard-adjacent box, which is exactly what a real reader typing a long message sees \
happening now!!!";
const TYPE_CHAR_MS: u64 = 50;
const KEYBOARD_CYCLES: usize = 5;
const KEYBOARD_WAIT_MS: u64 = 1_000;
const POLL_MS: u64 = 16;
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
pub struct BenchClient {
ui_state: AndroidUiState,
content: WeakWidget<WidgetPtr>,
report_display: WeakWidget<TextEdit>,
top_bar: WeakWidget<WidgetPtr>,
screen: Option<crate::ui::TranscriptScreen>,
items: Vec<TranscriptItem>,
stream_tail: Vec<SeqEvent>,
platform: Option<Arc<PlatformHandle>>,
last_report: Option<String>,
running: bool,
ime_state: Arc<Mutex<ImeState>>,
keyboard_was_visible: bool,
last_top_pad: f32,
}
#[derive(Default)]
struct ImeState {
visible: bool,
shown_events: u32,
hidden_events: u32,
}
impl HasAndroidUiState for BenchClient {
fn android_state(&self) -> &AndroidUiState {
&self.ui_state
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
&mut self.ui_state
}
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
.any()
}
fn process_cpu_ms() -> Option<u64> {
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
// initialises on success; on failure it is never read.
unsafe {
let mut usage: libc::rusage = std::mem::zeroed();
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
return None;
}
let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000;
let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000;
Some(user_ms + sys_ms)
}
}
fn peak_rss_kb() -> Option<u64> {
std::fs::read_to_string("/proc/self/status")
.ok()?
.lines()
.find_map(|line| line.strip_prefix("VmHWM:"))
.and_then(|rest| rest.trim().strip_suffix("kB"))
.and_then(|n| n.trim().parse().ok())
}
fn battery_line(samples: &[i32]) -> String {
if samples.is_empty() {
return " battery current: unavailable on this device".to_string();
}
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
unreachable!("samples is non-empty, checked above");
};
format!(
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
samples.len()
)
}
impl AndroidAppState for BenchClient {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
let content = WidgetPtr::new().add(rsc);
let loading = placeholder(rsc, "Loading fixture...");
content(rsc).set(loading);
let report_display = wtext("")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(true)
.size(14)
.color(PaintId::WHITE)
.attr::<Selectable>(())
.label("Benchmark report")
.add(rsc);
let top_bar = WidgetPtr::new().add(rsc);
let controls = bench_controls(rsc, 0.0);
top_bar(rsc).set(controls);
let tree = (
top_bar,
report_display
.pad(dp(8))
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
content.height(rest(1)),
)
.span(Dir::DOWN)
.add_strong(rsc)
.any();
ui_state.set_root(rsc, tree);
let font = rsc.ui.text.font_diagnostics();
log::info!(
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
bold={:?} italic={:?} mono={:?}, icons={:?}",
font.families_found,
font.default_family,
font.default_mono_family,
font.regular_resolved,
font.bold_resolved,
font.italic_resolved,
font.mono_resolved,
font.icon_family,
);
let mut client = Self {
ui_state,
content,
report_display,
top_bar,
screen: None,
items: Vec::new(),
stream_tail: Vec::new(),
platform: None,
last_report: None,
running: false,
ime_state: Arc::new(Mutex::new(ImeState::default())),
keyboard_was_visible: false,
last_top_pad: 0.0,
};
match crate::ui::fixture::build_screen(rsc) {
Ok((opened, tree)) => {
client.items = opened.items;
client.stream_tail = opened.stream_tail;
(client.content)(rsc).set(tree);
client.screen = Some(opened.screen);
}
Err(message) => {
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
}
}
client
}
fn platform_ready(&mut self, _rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
}
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
false
}
fn on_insets_changed(
&mut self,
rsc: &mut AndroidRsc<Self>,
insets: iris::android::WindowInsets,
) {
if insets.top != self.last_top_pad {
self.last_top_pad = insets.top;
let controls = bench_controls(rsc, insets.top);
(self.top_bar)(rsc).set(controls);
}
if let Some(screen) = &self.screen {
screen
.composer
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
}
// The platform's own answer, not `ime_bottom > 0.0` -- see
// `iris::android::WindowInsets::ime_bottom`. The height is still
// climbing while the keyboard slides in, so a frame or two of a
// real opening reads as "closed" when the boolean is inferred from
// it, and `shown_events`/`hidden_events` below count transitions.
let ime_visible = insets.ime_visible;
let mut ime = self.ime_state.lock().unwrap();
if ime_visible && !ime.visible {
ime.shown_events += 1;
}
if !ime_visible && ime.visible {
ime.hidden_events += 1;
}
ime.visible = ime_visible;
drop(ime);
if ime_visible && !self.keyboard_was_visible {
self.keyboard_was_visible = true;
let redraw = rsc.tasks.redraw_handle();
rsc.spawn_task(async move |mut ctx| {
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
ctx.update(|state: &mut BenchClient, rsc| {
state.capture_keyboard_diagnostics(rsc);
});
redraw.request_redraw();
});
} else if !ime_visible {
self.keyboard_was_visible = false;
}
}
}
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
type Rsc = AndroidRsc<BenchClient>;
/// What a report says about the `iris::input`/`iris::frame` trace, from
/// the flag read at the start of what is being reported and again at the
/// end.
///
/// Three answers rather than two. Those lines are default-off and the
/// switch that turns them on is on screen while a benchmark runs, so
/// "somebody moved it half way through" is a state that actually happens
/// -- and reported as either "on" or "off" it is a confident sentence
/// about a log that only covers part of the run. The "on" wording also
/// says what it costs, because a traced run fills the ring in seconds and
/// a reader looking at a log with nothing else in it should know why.
fn trace_line(at_start: bool, at_end: bool) -> String {
match (at_start, at_end) {
(true, true) => "input/frame trace: on (iris::input and iris::frame lines are in \
the app log, and a traced run fills the ring in seconds)"
.to_string(),
(false, false) => "input/frame trace: off".to_string(),
_ => "input/frame trace: switched during this run, so those lines cover only part \
of it"
.to_string(),
}
}
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
/// why it needs one at all. A dark neutral rather than pure black
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
/// instead of a hole in the background the buttons happen to float in.
const HEADER_SURFACE: Srgba8 = Srgba8::new(28, 28, 34, 255);
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
/// `on_insets_changed` has run once) -- folded in here, rather than
/// exposing the unadded builder for a caller to `.pad()` itself, because
/// naming that builder's type at each call site is more machinery than a
/// top-of-screen padding number is worth.
const HEADER_TEXT: f32 = 18.0;
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let run_rect = rect(Srgba8::rgb(40, 70, 40))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.start_benchmark(rsc);
},
)
.label("Run benchmark");
let run = (
run_rect,
wtext("Run benchmark")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let copy_rect = rect(Srgba8::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.copy_report(rsc);
},
)
.label("Copy report");
let copy = (
copy_rect,
wtext("Copy report")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let diag_rect = rect(Srgba8::rgb(60, 45, 70))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.show_diagnostics(rsc);
},
)
.label("Diagnostics");
let diagnostics = (
diag_rect,
wtext("Diagnostics")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
// A switch rather than a button, so its own appearance says which
// state it is in: the two `iris::input`/`iris::frame` targets are
// default-off (`iris::diagnostics`'s module doc) because a 120Hz
// session fills the 2000-line ring in seconds, so "is it on right
// now" is the question somebody has while looking at a log that is
// either full of trace or has none.
let tracing = iris::diagnostics::trace_enabled();
let trace_rect = rect(if tracing {
Srgba8::rgb(90, 70, 30)
} else {
Srgba8::rgb(50, 50, 60)
})
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.toggle_trace(rsc);
},
)
.label("Trace input and frames");
let trace = (
trace_rect,
wtext(if tracing { "Trace on" } else { "Trace off" })
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
(rect(HEADER_SURFACE), buttons)
.stack()
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
.pad(Padding::top(top_pad))
.add_strong(rsc)
.any()
}
impl BenchClient {
fn show_message(&mut self, rsc: &mut Rsc, message: &str) {
let widget = placeholder(rsc, message);
(self.content)(rsc).set(widget);
self.screen = None;
}
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
let (screen, tree) = crate::ui::build_tree(rsc, crate::ui::fixture::rows(&self.items));
(self.content)(rsc).set(tree);
self.screen = Some(screen);
}
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
let report = self.diagnostics_text(rsc);
self.report_display.edit(rsc).set(&report);
self.last_report = Some(report);
}
/// Turns the `iris::input`/`iris::frame` trace on or off, redraws the
/// switch that says so, and shows the pane that now reports it.
fn toggle_trace(&mut self, rsc: &mut Rsc) {
let on = !iris::diagnostics::trace_enabled();
iris::diagnostics::set_trace(on);
log::info!(
"iris diagnostics: input/frame trace {}",
if on { "on" } else { "off" }
);
let controls = bench_controls(rsc, self.last_top_pad);
(self.top_bar)(rsc).set(controls);
self.show_diagnostics(rsc);
}
/// The diagnostics report as text, with no side effect on what is on
/// screen -- shared by the `Diagnostics` button (which shows it) and
/// the keyboard-open capture (which only logs it), so the two can
/// never drift into reporting different things.
fn diagnostics_text(&self, rsc: &mut Rsc) -> String {
let font = rsc.ui.text.font_diagnostics();
let frame_report = match self.android_state().frame_report.report() {
Some(stats) => format!("{stats}"),
None => "no frames recorded yet".to_string(),
};
let renderer = match &self.android_state().renderer {
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
};
// Insets must be visible without adb so a missing callback can be
// distinguished from a callback reporting zero IME height.
format!(
"{renderer}\n{}\n{}\n{}\n{}",
trace_line(
iris::diagnostics::trace_enabled(),
iris::diagnostics::trace_enabled()
),
self.android_state().insets_report(),
// Which server this build talks to, and what to do when the
// answer is "none" -- the bench itself opens a checked-in
// fixture and needs no server, so this pane is the only place
// an enrolment can be seen to have taken.
crate::android::enrollment::status_line(),
crate::android::app_log::diagnostics_line()
)
}
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
let report = self.diagnostics_text(rsc);
log::info!("iris keyboard diagnostics:\n{report}");
}
fn copy_report(&mut self, rsc: &mut Rsc) {
let Some(platform) = &self.platform else {
log::info!("iris bench report: no platform handle, can't reach the clipboard");
return;
};
let report = match self.last_report.clone() {
Some(report) => report,
None => format!(
"no benchmark has run yet -- these are the diagnostics instead:\n\n{}",
self.diagnostics_text(rsc)
),
};
if platform.copy_to_clipboard("iris bench report", &report) {
log::info!("iris bench report: copied to clipboard");
} else {
log::info!("iris bench report: clipboard copy failed");
}
}
fn start_benchmark(&mut self, rsc: &mut Rsc) {
if self.running {
log::info!("iris bench report: already running");
return;
}
self.running = true;
self.android_state_mut().frame_report.reset();
self.report_display.edit(rsc).set("Running benchmark...");
let redraw = rsc.tasks.redraw_handle();
let platform = self.platform.clone();
let stream_tail = self.stream_tail.clone();
let ime_state = self.ime_state.clone();
let platform_hz = platform.as_ref().and_then(|p| p.refresh_rate_hz());
let cpu_start = process_cpu_ms();
// Read at the start as well as the end, because the switch is on
// screen while a run is going: a report that only asked afterwards
// would say "on" about a run whose first half has no trace in it
// -- the inferred answer presented as the measured one.
let trace_at_start = iris::diagnostics::trace_enabled();
let run_started_at = Instant::now();
rsc.spawn_task(async move |mut ctx| {
// The battery sampler runs for the whole run, once a second,
// the same cadence `BatterySampler` uses on the Compose side
// -- via its own JNI-attached thread, not `ctx.update`, since
// a sample needs no widget-tree access.
let sampler_done = Arc::new(AtomicBool::new(false));
let samples = Arc::new(Mutex::new(Vec::<i32>::new()));
let sampler = platform.clone().map(|platform| {
let done = sampler_done.clone();
let samples = samples.clone();
tokio::spawn(async move {
while !done.load(Ordering::Relaxed) {
if let Some(value) = platform.battery_current_ua() {
samples.lock().unwrap().push(value);
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
})
});
let travel = run_fling_phase(&mut ctx, &redraw).await;
let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
run_type_phase(&mut ctx, &redraw, &platform).await;
let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
sampler_done.store(true, Ordering::Relaxed);
if let Some(sampler) = sampler {
let _ = sampler.await;
}
let battery = battery_line(&samples.lock().unwrap());
let cpu_line = match (cpu_start, process_cpu_ms()) {
(Some(start), Some(end)) => {
format!(
" process CPU time over this run: {}ms",
end.saturating_sub(start)
)
}
_ => " process CPU time over this run: unavailable".to_string(),
};
let rss_line = match peak_rss_kb() {
Some(kb) => format!(" peak RSS: {kb}kB"),
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
};
let total_seconds = run_started_at.elapsed().as_secs_f64();
ctx.update(move |state: &mut BenchClient, rsc| {
state.running = false;
let now = Instant::now();
let drawn_hz = state.android_state().frame_report.sustained_frame_hz();
let refresh_hz = match (drawn_hz, platform_hz) {
(Some(d), Some(p)) => d.max(p),
(Some(d), None) => d,
(None, Some(p)) => p,
(None, None) => 60.0,
};
let hz_line = match (drawn_hz, platform_hz) {
(Some(d), Some(p)) if d > p + 5.0 => format!(
" (sustained {d:.0}fps, so at least that; the display reported {p:.0}Hz)"
),
(Some(d), Some(_)) => format!(" (as the display reports it; drew {d:.0}fps)"),
(Some(d), None) => {
format!(" (sustained {d:.0}fps; the display would not say)")
}
(None, Some(_)) => {
" (as the display reports it; too few frames to measure)".to_string()
}
(None, None) => " (assumed -- neither measured nor reported)".to_string(),
};
let phase_lines: String = state
.android_state()
.frame_report
.phase_stats(now, refresh_hz)
.iter()
.map(|p| format!("{p}\n"))
.collect();
let per_phase = if phase_lines.is_empty() {
String::new()
} else {
format!("per phase:\n{phase_lines}\n")
};
let frames_block = match state.android_state().frame_report.report() {
Some(stats) => {
let (late, late_pct) =
state.android_state().frame_report.late_at_hz(refresh_hz);
format!(
"frames:\n {} frames over {:.1}s at {:.0}Hz{hz_line} ({:.1}ms \
budget)\n \
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
p99 {:.1}ms\n worst {:.1}ms\n build_p50 {:.1}ms acquire_p50 \
{:.1}ms submit_p50 {:.1}ms",
stats.total_frames,
total_seconds,
refresh_hz,
1000.0 / refresh_hz as f64,
stats.p50.as_secs_f64() * 1000.0,
stats.p90.as_secs_f64() * 1000.0,
stats.p99.as_secs_f64() * 1000.0,
stats.worst.as_secs_f64() * 1000.0,
stats.cpu_p50.as_secs_f64() * 1000.0,
stats.acquire_p50.as_secs_f64() * 1000.0,
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
)
}
None => "frames:\n no frames recorded".to_string(),
};
let scroll_line = format!(
" scroll: {LEGACY_CYCLES} cycles ({} swipes, legacy tween), streamed \
{sent}/{total} fixture events",
LEGACY_CYCLES * 4
);
let fling_line = format!(
" fling: {FLING_COUNT} flings out + {FLING_COUNT} back at \
{FLING_VELOCITY_PX_S}px/s, travel {travel}"
);
let type_line = format!(
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
TYPE_TEXT.chars().count()
);
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
let report = format!(
"iris bench report\n{traced}\n{per_phase}{frames_block}\n\nbench:\n\
{fling_line}\n{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n\
{rss_line}\n{battery}"
);
log::info!("iris bench report: {report}");
state.report_display.edit(rsc).set(&report);
state.last_report = Some(report);
});
redraw.request_redraw();
});
}
}
/// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
/// same `ctx.update` every other mutation here goes through) and returns
/// its result to the caller's async task -- `ctx.update` alone has no way
/// to hand a value back, since the closure only actually runs once the
/// next frame callback drains `IrisViewPeer`'s task channel
/// (`drain_tasks`). **Must call `redraw.request_redraw()` itself, right
/// after enqueueing** -- `ctx.update` only ever pushes onto a channel;
/// nothing drains it until something schedules the frame callback that
/// calls `drain_tasks`, and a caller relying on some *earlier*,
/// already-in-flight `request_redraw()` to cover a *later* `ctx.update`
/// deadlocks the moment that earlier callback has already fired and
/// drained everything queued before this call existed. Cost a real hang
/// in this file's first version of the fling phase: every loop iteration
/// after the first sat forever with nothing scheduled to drain it.
/// Polls rather than assuming one `POLL_MS` sleep is enough, since a
/// slow device's frame callback can lag further than that.
async fn read_from_state<T, F>(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
f: F,
) -> T
where
T: Send + 'static,
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
{
let (tx, rx) = std::sync::mpsc::channel();
ctx.update(move |state: &mut BenchClient, rsc| {
let _ = tx.send(f(state, rsc));
});
redraw.request_redraw();
loop {
if let Ok(value) = rx.try_recv() {
return value;
}
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
}
}
async fn run_fling_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) -> String {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("fling");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
// `anchor = None` into a real slot before `start` is read.
tokio::time::sleep(Duration::from_millis(POLL_MS * 2)).await;
let start = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
animate_scroll(screen.list, rsc);
}
});
redraw.request_redraw();
wait_for_fling_settle(ctx, redraw).await;
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
}
let outward = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
animate_scroll(screen.list, rsc);
}
});
redraw.request_redraw();
wait_for_fling_settle(ctx, redraw).await;
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
}
let end = read_anchor_position(ctx, redraw).await;
format!("start={start} outward={outward} end={end} ticked=frame-loop")
}
async fn read_anchor_position(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) -> String {
read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).anchor_position_display(),
None => "idx=none".to_string(),
})
.await
}
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
let id = scroll.id();
rsc.ui_mut().animate(id);
}
async fn wait_for_fling_settle(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) {
let cap = Duration::from_millis(FLING_SETTLE_CAP_MS);
let started = Instant::now();
while started.elapsed() < cap {
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).is_scrolling(),
None => false,
})
.await;
if !still_scrolling {
return;
}
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
}
}
async fn run_stream_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
stream_tail: Vec<SeqEvent>,
) -> (usize, usize) {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("stream");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
let mut sent = 0usize;
for event in stream_tail.into_iter().take(total) {
ctx.update(move |state: &mut BenchClient, rsc| {
let old_items = state.items.clone();
state.items = fold_event(&state.items, &event);
match &state.screen {
Some(screen) => screen.apply(rsc, &old_items, &state.items),
None => state.rebuild_transcript(rsc),
}
});
redraw.request_redraw();
sent += 1;
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
}
tokio::time::sleep(Duration::from_millis(300)).await;
(sent, total)
}
async fn run_type_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
platform: &Option<Arc<PlatformHandle>>,
) {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("type");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
state.set_focus(Some(screen.composer.field));
}
});
redraw.request_redraw();
if let Some(p) = platform {
p.show_ime();
}
tokio::time::sleep(Duration::from_millis(300)).await;
let mut typed = String::new();
for ch in TYPE_TEXT.chars() {
typed.push(ch);
let text = typed.clone();
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
screen.composer.field.edit(rsc).set(&text);
}
});
redraw.request_redraw();
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
while !typed.is_empty() {
typed.pop();
let text = typed.clone();
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
screen.composer.field.edit(rsc).set(&text);
}
});
redraw.request_redraw();
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
}
}
async fn run_keyboard_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
platform: &Option<Arc<PlatformHandle>>,
ime_state: &Arc<Mutex<ImeState>>,
) -> String {
ctx.update(|state: &mut BenchClient, _rsc| {
state
.android_state_mut()
.frame_report
.mark_phase("keyboard");
});
let mut shown = 0;
let mut hidden = 0;
for _ in 0..KEYBOARD_CYCLES {
let before_shown = ime_state.lock().unwrap().shown_events;
if let Some(p) = platform {
p.show_ime();
}
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
if ime_state.lock().unwrap().shown_events > before_shown {
shown += 1;
}
let before_hidden = ime_state.lock().unwrap().hidden_events;
if let Some(p) = platform {
p.hide_ime();
}
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
if ime_state.lock().unwrap().hidden_events > before_hidden {
hidden += 1;
}
}
if shown == 0 {
format!(" keyboard: could not be shown ({KEYBOARD_CYCLES} attempts, 0 confirmed visible)")
} else {
format!(
" keyboard: shown {shown}/{KEYBOARD_CYCLES}, hidden {hidden}/{KEYBOARD_CYCLES} \
(confirmed via on_insets_changed)"
)
}
}
#[cfg(test)]
mod tests {
use super::TYPE_TEXT;
#[test]
fn type_text_is_exactly_600_characters() {
assert_eq!(TYPE_TEXT.chars().count(), 600);
}
}
@@ -1,21 +1,3 @@
//! JNI calls the `bench` feature needs that go through the shell's own
//! Java side rather than anything `iris`/`android-view` already wraps:
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
//! per-second battery sample, and `ClipboardManager.setPrimaryClip` for
//! the "Copy report" control (P0's iris half, docs/RUST.md). Neither is
//! part of `android_view::context`'s own `Context`/`Resources` wrappers
//! (that file's own `// TODO: more methods?`), so this calls them
//! directly rather than growing that crate's wrapper for two one-off
//! calls this crate alone needs.
//!
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach
//! whichever thread calls it -- the battery sampler runs on a background
//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls
//! run on. `JavaVM::attach_current_thread` is safe to call from a thread
//! already attached (the `jni` crate detects it and does not double
//! attach), so no caller here needs to know or care which thread it is.
use android_view::jni::{
JNIEnv, JavaVM,
objects::{GlobalRef, JObject, JValue},
@@ -66,13 +48,6 @@ impl PlatformHandle {
.ok()
}
/// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None`
/// on any JNI failure, on a device with no `BatteryManager` service,
/// or when the platform itself answers "not supported" -- `0` or
/// `Integer.MIN_VALUE` are both documented SDK answers for that, and
/// both would read as a real (and wrong) measurement if folded into an
/// average rather than named apart. UI_RULES.md: never present an
/// inferred value as a measured one.
pub fn battery_current_ua(&self) -> Option<i32> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
@@ -131,4 +106,74 @@ impl PlatformHandle {
.ok()?;
Some(())
}
pub fn refresh_rate_hz(&self) -> Option<f32> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let display = env
.call_method(
self.view.as_obj(),
"getDisplay",
"()Landroid/view/Display;",
&[],
)
.ok()?
.l()
.ok()?;
if display.is_null() {
return None;
}
let rate = env
.call_method(&display, "getRefreshRate", "()F", &[])
.ok()?
.f()
.ok()?;
if rate > 0.0 { Some(rate) } else { None }
}
pub fn show_ime(&self) -> bool {
self.try_toggle_ime(true).unwrap_or(false)
}
pub fn hide_ime(&self) -> bool {
self.try_toggle_ime(false).unwrap_or(false)
}
fn try_toggle_ime(&self, show: bool) -> Option<bool> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let imm = self.system_service(env, &context, "input_method")?;
if show {
env.call_method(
&imm,
"showSoftInput",
"(Landroid/view/View;I)Z",
&[JValue::Object(self.view.as_obj()), JValue::Int(0)],
)
.ok()?
.z()
.ok()
} else {
let token = env
.call_method(
self.view.as_obj(),
"getWindowToken",
"()Landroid/os/IBinder;",
&[],
)
.ok()?
.l()
.ok()?;
env.call_method(
&imm,
"hideSoftInputFromWindow",
"(Landroid/os/IBinder;I)Z",
&[JValue::Object(&token), JValue::Int(0)],
)
.ok()?
.z()
.ok()
}
}
}
+160
View File
@@ -0,0 +1,160 @@
//! The JNI half of `DevLogProvider`: reading this process's own log ring
//! for a `ContentProvider` that Dev Updater queries.
use android_view::jni::JNIEnv;
use android_view::jni::objects::{JClass, JObject, JString};
use android_view::jni::sys::{jlong, jobjectArray};
use std::sync::OnceLock;
/// Gated with its one reader: the tabs demo links no `client-core` and so
/// has no ring to lay out, and an ungated constant is a warning in that
/// build (`iris-android-app` without `transcript-screen`).
#[cfg(feature = "transcript-screen")]
const FIELDS_PER_LINE: usize = 5;
/// The authority the provider registered itself under, once it has been
/// created. `None` until then, which is a state worth being able to say:
/// a provider Android never instantiated and one that is answering look
/// the same from inside this process otherwise.
static AUTHORITY: OnceLock<String> = OnceLock::new();
#[cfg(feature = "bench")]
pub fn authority() -> Option<&'static str> {
AUTHORITY.get().map(String::as_str)
}
/// The directory is taken here as well as in
/// `MainActivity.nativeSetFilesDir` because **the provider is often the
/// only thing running**: once the app has died, Dev Updater's query
/// starts the process for the provider alone, so no activity ever runs
/// and the panic hook's file would never be replayed into the ring. That
/// is precisely the run whose log is being asked for. Whichever of the
/// two arrives first does the replay; `set_crash_dir` deletes the file,
/// so the second finds nothing and says nothing.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
mut env: JNIEnv,
_class: JClass,
authority: JString,
files_dir: JString,
) {
#[cfg(feature = "transcript-screen")]
if let Some(dir) = string_arg(&mut env, &files_dir) {
crate::android::app_log::set_crash_dir(std::path::Path::new(&dir));
}
#[cfg(not(feature = "transcript-screen"))]
let _ = &files_dir;
let Some(authority) = string_arg(&mut env, &authority) else {
return;
};
log::info!("iris devlog: serving this app's log at content://{authority}");
let _ = AUTHORITY.set(authority);
}
fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
if value.is_null() {
return None;
}
env.get_string(value).ok().map(Into::into)
}
/// `newest_seq` is `-1` for a ring nothing has been written to, which is
/// what tells a reader holding a cursor that this process **restarted**:
/// the ring is in memory, so a new process starts again at zero and a
/// stale cursor would otherwise skip everything silently.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeStatus(
mut env: JNIEnv,
_class: JClass,
) -> jobjectArray {
string_array(&mut env, &status_fields())
}
/// Inclusive of `since` because [`crate::client::log_ring::LogRing::since`]
/// is, and one definition of the cursor is what keeps the app's own
/// uploaded report and this provider describing the same lines.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeLinesSince(
mut env: JNIEnv,
_class: JClass,
since: jlong,
) -> jobjectArray {
// A negative cursor is a caller asking for everything, not an error to
// take the app down over: the provider is a diagnostic.
string_array(&mut env, &line_fields(since.max(0) as u64))
}
#[cfg(feature = "transcript-screen")]
fn status_fields() -> Vec<String> {
let ring = crate::client::log_ring::process_ring();
vec![
ring.len().to_string(),
ring.dropped().to_string(),
ring.newest_seq().map_or(-1, |seq| seq as i64).to_string(),
]
}
/// The tabs demo links no `client-core` and keeps no ring, so it holds
/// nothing and has never dropped anything -- which is the truth, not a
/// stand-in. The natives are still exported there, because a `native`
/// method Java declares and the library does not is an
/// `UnsatisfiedLinkError` the moment the class loads.
#[cfg(not(feature = "transcript-screen"))]
fn status_fields() -> Vec<String> {
vec!["0".to_string(), "0".to_string(), "-1".to_string()]
}
#[cfg(feature = "transcript-screen")]
fn line_fields(since: u64) -> Vec<String> {
let (lines, _next) = crate::client::log_ring::process_ring().since(since);
let mut fields = Vec::with_capacity(lines.len() * FIELDS_PER_LINE);
for line in lines {
fields.push(line.seq.to_string());
fields.push(line.at_ms.to_string());
fields.push(line.level.to_string());
fields.push(line.target);
fields.push(line.message);
}
fields
}
#[cfg(not(feature = "transcript-screen"))]
fn line_fields(_since: u64) -> Vec<String> {
Vec::new()
}
/// Null rather than a panic across the JNI boundary: `DevLogProvider`
/// reads it as "the provider could not answer" and returns no cursor,
/// which Dev Updater already draws as a distinct state. Taking the app
/// down to report that its diagnostic is unavailable would be worse than
/// the diagnostic being unavailable.
fn string_array(env: &mut JNIEnv, fields: &[String]) -> jobjectArray {
let null = std::ptr::null_mut();
let Ok(class) = env.find_class("java/lang/String") else {
return null;
};
let Ok(array) = env.new_object_array(fields.len() as i32, class, JObject::null()) else {
return null;
};
for (index, field) in fields.iter().enumerate() {
let Ok(value) = env.new_string(field) else {
return null;
};
if env
.set_object_array_element(&array, index as i32, value)
.is_err()
{
return null;
}
}
array.into_raw()
}
+87
View File
@@ -0,0 +1,87 @@
#[cfg(not(feature = "bench"))]
use crate::client::api::UreqTransport;
use crate::client::config::{EnrolledServer, EnrollmentStore};
use std::path::PathBuf;
use std::sync::OnceLock;
static FILES_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn set_files_dir(dir: PathBuf) {
if let Err(existing) = FILES_DIR.set(dir.clone()) {
assert_eq!(
existing, dir,
"the app's files directory was set twice with different paths"
);
}
}
fn store() -> Option<EnrollmentStore> {
FILES_DIR.get().map(EnrollmentStore::new)
}
pub enum Status {
Enrolled(EnrolledServer),
NotEnrolled,
/// The question could not be answered -- the activity never handed a
/// files directory over, or the file is there and unreadable. Kept
/// apart from `NotEnrolled` because the two want different actions
/// from whoever is looking.
Unknown(String),
}
pub fn status() -> Status {
let Some(store) = store() else {
return Status::Unknown("the activity never handed over a files directory".to_string());
};
match store.load() {
Ok(Some(server)) => Status::Enrolled(server),
Ok(None) => Status::NotEnrolled,
Err(error) => Status::Unknown(error.to_string()),
}
}
/// One line for the diagnostics pane. The three states read differently on
/// purpose: "not enrolled" says what to do about it, and "couldn't tell"
/// must not be mistaken for it.
#[cfg(feature = "bench")]
pub fn status_line() -> String {
match status() {
Status::Enrolled(server) => format!("enrolled: {}:{}", server.host, server.port),
Status::NotEnrolled => "not enrolled -- open the enrol link from Dev Updater".to_string(),
Status::Unknown(why) => format!("enrolment unreadable: {why}"),
}
}
pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
let server = EnrolledServer::parse_link(uri)?;
let store = store().ok_or("the app has no files directory to save an enrollment in")?;
store
.save(&server)
.map_err(|error| format!("couldn't save the enrollment: {error}"))?;
Ok(server)
}
/// Gated to the same builds as `transcript_client`, its only caller: the
/// bench build opens a checked-in fixture and reaches no server, so
/// compiling this into it would be a warning about dead code that is
/// dead on purpose.
///
/// Every failure here is a sentence a screen can show, because there is
/// nowhere else for it to go: this app has no `logcat` on the phone it is
/// built for.
#[cfg(not(feature = "bench"))]
pub fn transport() -> Result<UreqTransport, String> {
let server = match status() {
Status::Enrolled(server) => server,
Status::NotEnrolled => {
return Err("Not enrolled yet -- open the enrol link from Dev Updater.".to_string());
}
Status::Unknown(why) => return Err(format!("Couldn't read the enrollment: {why}")),
};
let ca_pem = server.ca_pem.as_ref().ok_or(
"The enrollment link carried no CA, so there is nothing to pin. \
Enrol again with a link minted by this server.",
)?;
UreqTransport::new(server.base_url(), &server.token, ca_pem.as_bytes())
.map_err(|error| error.message)
}
+168
View File
@@ -0,0 +1,168 @@
use android_view::{
Context, View,
jni::{
JNIEnv, JavaVM,
objects::{JClass, JString},
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
},
register_view_class,
};
#[cfg(not(feature = "transcript-screen"))]
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
#[cfg(not(feature = "transcript-screen"))]
use iris::prelude::*;
use log::LevelFilter;
use std::ffi::c_void;
/// The app's own log ring and its upload -- only where `client-core` is
/// linked, which is every build that has a server to send to. The plain
/// tabs demo keeps `android_logger` alone, as it always had.
#[cfg(feature = "transcript-screen")]
mod app_log;
#[cfg(feature = "bench")]
mod bench_client;
#[cfg(feature = "bench")]
mod bench_jni;
mod devlog;
#[cfg(feature = "transcript-screen")]
mod enrollment;
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
mod transcript_client;
/// The app's `View` subclass, matching the Java side's package --
/// `app/src/main/java/dev/iris/android/demo/IrisView.java`.
const VIEW_CLASS: &str = "dev/iris/android/demo/IrisView";
#[cfg(not(feature = "transcript-screen"))]
pub struct Client {
ui_state: AndroidUiState,
}
#[cfg(not(feature = "transcript-screen"))]
impl HasAndroidUiState for Client {
fn android_state(&self) -> &AndroidUiState {
&self.ui_state
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
&mut self.ui_state
}
}
#[cfg(not(feature = "transcript-screen"))]
impl AndroidAppState for Client {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
// `widgets.info` is the winit example's frame-debug readout, kept
// current from `DefaultAppState::window_event` -- android-view has
// no per-frame hook to drive the equivalent from here yet, so it
// is left at its built "" text rather than wired to nothing.
let _ = tabs_ui::build(rsc, &mut ui_state);
Self { ui_state }
}
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
false
}
}
#[cfg(not(feature = "transcript-screen"))]
type ActiveClient = Client;
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
type ActiveClient = transcript_client::TranscriptClient;
#[cfg(feature = "bench")]
type ActiveClient = bench_client::BenchClient;
extern "system" fn new_view_peer<'local>(
env: JNIEnv<'local>,
view: View<'local>,
context: Context<'local>,
) -> jlong {
iris::android::new_peer::<ActiveClient>(env, view, context)
}
/// # Safety
/// Interacting with JNI at load time is always unsafe at some level --
/// mirrors android-view's own demo, which carries the same comment.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
// The ring in front of `android_logger` where there is one (see
// `app_log`), and `android_logger` alone otherwise. Both install the
// same tag and level, so `logcat` cannot tell the two builds apart --
// the ring only adds a second reader.
#[cfg(feature = "transcript-screen")]
app_log::install(LevelFilter::Debug);
#[cfg(not(feature = "transcript-screen"))]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(LevelFilter::Debug)
.with_tag("iris-android-app"),
);
let vm = unsafe { JavaVM::from_raw(vm) }.unwrap();
let mut env = vm.get_env().unwrap();
register_view_class(&mut env, VIEW_CLASS, new_view_peer);
iris::android::register_native_methods(&mut env, VIEW_CLASS);
JNI_VERSION_1_6
}
/// `MainActivity.nativeSetFilesDir` -- the app's private directory, handed
/// over before the view exists because that is where the enrollment is
/// read from and written to (`enrollment`'s module doc).
///
/// Exported by name rather than registered through `RegisterNatives`: the
/// view's methods are registered because `android-view` owns that class
/// and hands out one function pointer, whereas these two are this app's
/// own activity and the mangled name is the whole of what is needed.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir(
mut env: JNIEnv,
_class: JClass,
dir: JString,
) {
let Some(dir) = jstring(&mut env, dir) else {
return;
};
#[cfg(feature = "transcript-screen")]
{
app_log::set_crash_dir(std::path::Path::new(&dir));
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
}
log::debug!("iris app: files directory is {dir}");
}
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll(
mut env: JNIEnv,
_class: JClass,
uri: JString,
) {
let Some(uri) = jstring(&mut env, uri) else {
return;
};
#[cfg(feature = "transcript-screen")]
match enrollment::apply_link(&uri) {
// Never the token: `wg-app-link`'s enroll module forbids logging
// it, and this line would otherwise be the one place it leaked.
Ok(server) => log::info!("iris app: enrolled with {}:{}", server.host, server.port),
Err(error) => log::warn!("iris app: that enrolment link was refused -- {error}"),
}
#[cfg(not(feature = "transcript-screen"))]
log::warn!("iris app: {uri} arrived, but this build has no server to enrol with");
}
fn jstring(env: &mut JNIEnv, value: JString) -> Option<String> {
if value.is_null() {
log::warn!("iris app: the activity passed a null string across JNI");
return None;
}
match env.get_string(&value) {
Ok(value) => Some(value.into()),
Err(error) => {
log::warn!("iris app: couldn't read a string from the activity -- {error}");
None
}
}
}
@@ -1,47 +1,12 @@
//! RUST.md's I5 Android integration: `transcript-ui`'s screen filling the
//! whole window on android-view, against a real `ai-server` through
//! `client-core` -- the missing half `iris-android-app` (I2) only had for
//! `tabs-ui` until now. Behind the `transcript-screen` Cargo feature so the
//! plain build (`cargo ndk build`, no `--features`) stays exactly the tabs
//! demo I2/I4 already measured against.
//!
//! **Deliberate simplification, recorded rather than left to be
//! rediscovered (RUST.md's I5 box has the full account)**: there is no
//! session list and no enrollment UI here. The server, port, token and
//! pinned CA are baked in at build time (`build.rs`'s
//! `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA`), and the first
//! session `ApiClient::fetch_sessions` returns is opened automatically --
//! there is nothing to tap to get there, which is what `transcript-bench.sh`
//! and `ui-trace` need to land straight on the screen under test. A real
//! app needs `desktop-app`'s `EnrolledServer`/QR-link flow or E3's
//! Keystore-sealed `ServerConfig.kt`; building a second one of those was
//! not this pass's job.
//!
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
//! `client_core::transcript_fold`, a `generation` counter guarding against
//! a stale background response, and a full rebuild of the widget tree on
//! every event (same tradeoff, same reason: `push_row` cannot update a row
//! already on screen, and this rig's conversations are small). What
//! differs is only the redraw mechanism: android-view has no
//! `winit::EventLoopProxy`, so this uses `iris::task::Tasks::redraw_handle`
//! (new, added alongside this box) to request a frame after each
//! `TaskCtx::update` instead of relying on `Tasks::spawn`'s single
//! end-of-future redraw -- see that method's own doc for why.
use client_core::api::{ApiClient, UreqTransport};
use client_core::event_stream::{StreamItem, follow_session_events};
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
use crate::client::api::{ApiClient, UreqTransport};
use crate::client::event_stream::{StreamItem, follow_session_events};
use crate::client::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
mod pinned {
include!(concat!(env!("OUT_DIR"), "/pinned_config.rs"));
}
pub struct TranscriptClient {
ui_state: AndroidUiState,
/// The screen's own content -- everything under the fixed
@@ -50,21 +15,9 @@ pub struct TranscriptClient {
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
/// by rebuilding the session list beside it.
content: WeakWidget<WidgetPtr>,
screen: Option<transcript_ui::TranscriptScreen>,
/// The folded transcript as of the last rebuild -- kept here (not
/// re-derived) for the same reason `desktop-app`'s `Client::items`
/// exists: a live `StreamEvent` only carries one new wire event, and
/// `fold_event` needs everything folded so far to fold it in.
screen: Option<crate::ui::TranscriptScreen>,
items: Vec<TranscriptItem>,
/// The session currently open -- `None` only before the first fetch
/// resolves. Read back by `apply_event`'s rebuild, which has no session
/// id of its own (a live `SeqEvent` doesn't carry one).
session_id: Option<String>,
/// Bumped every time a new session load starts; a background response
/// checks it before touching state, so a slow reply for a session this
/// screen has moved on from can't overwrite what replaced it. There is
/// only ever one session here (no list to switch away to), but the
/// guard still matters for the *first* fetch racing a `stop`/`start`.
generation: Arc<AtomicU64>,
}
@@ -77,41 +30,22 @@ impl HasAndroidUiState for TranscriptClient {
}
}
/// Builds one `UreqTransport` from the config `build.rs` baked in. Called
/// twice per session load, same as `desktop-app`'s `build_transport`
/// closure -- `ApiClient` and the live-stream follow each need their own,
/// since `UreqTransport` holds its own `ureq::Agent`.
fn build_transport() -> Result<UreqTransport, String> {
let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT);
UreqTransport::new(
base_url,
pinned::TOKEN.to_string(),
pinned::CA_PEM.as_bytes(),
)
.map_err(|e| e.to_string())
crate::android::enrollment::transport()
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(Color::WHITE)
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
.any()
}
/// The two named controls RUST.md's I5 box ("Measurements taken" (b))
/// drives by name over `ui-trace`, e.g. `ui-trace record --do "tap 'Frame
/// report'"`. `dumpsys gfxinfo` cannot see this screen's own GPU-drawn
/// frames at all -- this is the screen's own equivalent of the Compose
/// app's "Copy render timings" control, logged rather than clipboarded
/// (no clipboard wiring exists here) under this crate's own fixed
/// `android_logger` tag (`iris-android-app`, `lib.rs`'s `JNI_OnLoad`),
/// grep-able on the fixed string `"iris frame report"` the way
/// `transcript-bench.sh` greps `"ai-app render report"`.
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
type Rsc = AndroidRsc<TranscriptClient>;
let report_rect = rect(Color::rgb(50, 50, 60))
let report_rect = rect(Srgba8::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
@@ -135,7 +69,7 @@ fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
.pad(8)
.add(rsc);
let reset_rect = rect(Color::rgb(70, 40, 40))
let reset_rect = rect(Srgba8::rgb(70, 40, 40))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
@@ -165,7 +99,7 @@ impl AndroidAppState for TranscriptClient {
.span(Dir::DOWN)
.add_strong(rsc)
.any();
ui_state.set_root(tree);
ui_state.set_root(rsc, tree);
let mut client = Self {
ui_state,
@@ -222,10 +156,6 @@ impl TranscriptClient {
});
}
/// Loads the opening page, then follows the live SSE stream for the
/// rest of this session's life -- `desktop-app`'s `select_session`
/// almost verbatim, with `Proxy::send_event` replaced by `ctx.update` +
/// `redraw.request_redraw()` (see this module's doc).
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
self.items.clear();
@@ -252,22 +182,14 @@ impl TranscriptClient {
};
let api = ApiClient::new(rest);
// The most recent 200 events, coalesced -- the same page size
// `desktop-app` uses; RUST.md's I3/history-paging work is what
// a real scrollback would reuse (out of scope here, same as
// E4).
let page: Result<Vec<serde_json::Value>, String> = api
.fetch_transcript_page(&session_id, None, 200, true)
.map_err(|e| e.to_string());
// The wire `seq` of the last line, not a folded item's `seq()`
// -- see `client_core::transcript_fold::raw_seq`'s doc for why
// resuming from the latter re-delivers deltas already folded
// into an in-progress reply.
let after = page
.as_ref()
.ok()
.and_then(|values| values.last())
.and_then(client_core::transcript_fold::raw_seq)
.and_then(crate::client::transcript_fold::raw_seq)
.unwrap_or(0);
let result = page.and_then(|values| fold_page(&values));
@@ -293,12 +215,6 @@ impl TranscriptClient {
if live_generation.load(Ordering::SeqCst) != my_generation {
return;
}
// The outer closure here is an `FnMut` -- `follow_session_events`
// calls it once per line -- so it captures `live_generation` by
// move and re-clones it for each inner `ctx.update` closure
// rather than moving a shared `stop`-style helper into itself:
// a value moved out of an `FnMut`'s captures on one call leaves
// nothing there for the next.
let _ =
follow_session_events(
&stream_transport,
@@ -327,10 +243,6 @@ impl TranscriptClient {
});
}
/// Rebuilds the whole widget tree from `self.items` -- same tradeoff as
/// `desktop-app`'s `rebuild_transcript` (this module's doc comment).
/// Reads `self.session_id` rather than taking one, since every caller
/// (the opening page, and every live event) already has it set there.
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
let in_progress = self
.screen
@@ -339,7 +251,7 @@ impl TranscriptClient {
.filter(|t| !t.is_empty());
let rows = group_tool_runs(&self.items);
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
let (screen, tree) = crate::ui::build_tree(rsc, rows);
if let Some(text) = in_progress {
screen.composer.field.edit(rsc).set(&text);
@@ -360,8 +272,12 @@ impl TranscriptClient {
}
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
let old_items = self.items.clone();
self.items = fold_event(&self.items, event);
self.rebuild_transcript(rsc);
match &self.screen {
Some(screen) => screen.apply(rsc, &old_items, &self.items),
None => self.rebuild_transcript(rsc),
}
}
fn send_message(&mut self, session_id: String, text: String) {
+9
View File
@@ -0,0 +1,9 @@
use ai_app::desktop::{app, startup};
fn main() {
if let Err(e) = startup::load_startup_config() {
eprintln!("desktop-app: {e}");
std::process::exit(2);
}
app::run();
}
@@ -1,25 +1,3 @@
//! What a tool printed, with its terminal styling applied and everything
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
//! here, so a [`StyledText`] of plain text plus non-overlapping
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
//! [`Style`] onto whatever it draws with.
//!
//! Bash output arrives exactly as the program wrote it, escape sequences
//! included, and drawn verbatim those are line noise in the middle of the
//! thing being read. Stripping them all would be the other half-answer --
//! colour is often the whole of what a diff or a test run is saying.
//!
//! So the sequences that decide how text *looks* become spans, and every
//! other one is dropped rather than shown: the rest move a cursor around a
//! grid this is not, and "go to column 40" has no meaning in a scrolling
//! document.
//!
//! A carriage return is honoured the way a terminal honours it: what was
//! written since the last line break is thrown away and the line starts
//! again. That is what makes a progress bar show its final state rather
//! than every state it passed through.
use std::ops::Range;
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
@@ -38,18 +16,10 @@ impl Rgb {
}
}
/// The sixteen colours a terminal program names, and the two it assumes.
///
/// Its own palette rather than the syntax one: a program that prints in red
/// has chosen red, where a highlighter's colours are this app's reading of
/// somebody else's code.
#[derive(Debug, Clone)]
pub struct AnsiPalette {
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
pub colours: [Rgb; 16],
/// What uncoloured text is, needed only where a style has to state a colour.
pub foreground: Rgb,
/// What the text sits on, needed for reverse video.
pub background: Rgb,
}
@@ -67,8 +37,6 @@ pub struct Style {
pub strikethrough: bool,
}
/// Plain text plus the non-overlapping, ordered spans that style parts of it
/// -- this crate's stand-in for Compose's `AnnotatedString`.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct StyledText {
pub text: String,
@@ -87,11 +55,7 @@ impl StyledText {
const ESC: char = '\u{1B}';
const BELL: char = '\u{7}';
/// [text] with its terminal styling applied and everything else taken out;
/// see the module doc.
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
// The common case by a long way -- nothing to do, and nothing allocated
// to find that out.
if !text.contains(ESC) && !text.contains('\r') {
return StyledText::plain(text.to_string());
}
@@ -118,19 +82,12 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
}
});
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
// A bare carriage return rewrites the line. One before a newline
// is the other half of a Windows line ending: it rewrites
// nothing, and it is dropped rather than kept, since that pair
// is one line break.
flush(&mut plain, sgr, &mut runs);
drop_line(&mut runs);
at += 1;
} else if c == '\r' {
at += 1;
} else if c >= ' ' || c == '\n' || c == '\t' {
// Everything printable, plus the two control characters that are
// layout rather than terminal commands. A stray bell or
// backspace goes for the same reason a cursor move does.
plain.push(c);
at += 1;
} else {
@@ -151,8 +108,6 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
StyledText { text: out, spans }
}
/// Throws away everything written since the last line break, as a carriage
/// return does.
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
while let Some((text, style)) = runs.pop() {
if let Some(break_at) = text.rfind('\n') {
@@ -162,7 +117,6 @@ fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
}
}
/// The bytes that end a CSI sequence.
fn is_csi_final(c: char) -> bool {
('@'..='~').contains(&c)
}
@@ -184,10 +138,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
end += 1;
}
if end >= chars.len() {
// Cut off mid-sequence, which is what a stream that has not
// finished arriving looks like: drop the fragment rather
// than printing it, and the whole sequence arrives with the
// next delta.
chars.len()
} else {
let params: String = chars[at + 2..end].iter().collect();
@@ -196,8 +146,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
}
}
']' | 'P' | 'X' | '^' | '_' => {
// Runs to a string terminator: `ESC \`, or the bell that xterm
// allows after an OSC.
let mut end = at + 2;
while end < chars.len() {
if chars[end] == BELL {
@@ -214,7 +162,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
}
}
/// Everything an SGR sequence can turn on, as the terminal tracks it.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Sgr {
fg: Option<Rgb>,
@@ -227,7 +174,6 @@ struct Sgr {
reverse: bool,
}
/// How much of its colour dim text keeps: enough to read, little enough to recede.
const DIM_ALPHA: f32 = 0.65;
impl Sgr {
@@ -242,7 +188,6 @@ impl Sgr {
reverse: false,
};
/// `None` while nothing is set, so unstyled output costs no spans at all.
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
if *self == Sgr::PLAIN {
return None;
@@ -275,15 +220,7 @@ impl Sgr {
})
}
/// This state with `params` applied -- one `ESC[...m`, which carries any
/// number of them.
///
/// A code this does not model is ignored rather than reset from: the
/// program meant something by it, and starting again would also drop
/// the codes beside it that are understood.
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
// zero too.
let codes: Vec<i64> = params
.split(';')
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
@@ -377,12 +314,6 @@ impl Sgr {
}
}
/// The colour named by a `38`/`48` at `at`, and the index of that colour's
/// last parameter.
///
/// Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal
/// one. The first sixteen of that table are the palette's own, so a program
/// asking for "colour 1" through either spelling gets the same red.
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
match codes.get(at + 1) {
Some(&5) => match codes.get(at + 2) {
@@ -409,11 +340,8 @@ fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<R
}
}
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
/// grey ramp.
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
if n < 0 {
palette.foreground
@@ -434,8 +362,6 @@ fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
mod tests {
use super::*;
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
/// white foreground, black background.
fn palette() -> AnsiPalette {
let mut colours = [Rgb::new(0, 0, 0); 16];
for (i, c) in colours.iter_mut().enumerate() {
@@ -452,8 +378,6 @@ mod tests {
ansi_styled(text, &palette())
}
/// The style covering the first character of `word`, or `None` where
/// nothing styles it.
fn style_over(text: &str, word: &str) -> Option<Style> {
let out = styled(text);
let at = out
@@ -509,8 +433,6 @@ mod tests {
#[test]
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
// A cursor move, an erase, an OSC window title with its bell, and a
// bare two-character escape.
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
assert_eq!(styled(&text).text, "abcde");
}
@@ -1,21 +1,9 @@
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
//! module doc for the table); the SSE half is [`crate::event_stream`].
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
//! which do not.
//!
//! Network I/O sits behind the [`Transport`] trait so the rest of this
//! crate, and anything built on it, can be tested against a fake one with
//! no server involved. [`UreqTransport`] is the only real implementation.
use std::io::Read;
use event_model::SeqEvent;
use serde::Deserialize;
use serde_json::Value;
/// A request that did not produce what it asked for, carrying the server's
/// own wording where it sent some.
///
/// `status` is the HTTP status where there was a response at all, and
/// `None` where the server was never reached -- mirroring `ApiException` in
/// `Api.kt`.
@@ -32,8 +20,6 @@ impl std::fmt::Display for ApiError {
}
impl std::error::Error for ApiError {}
/// A request body to send, in whichever of the two shapes the surface
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
pub enum Body {
Json(Value),
Bytes {
@@ -50,10 +36,7 @@ pub struct RawResponse {
pub body: Vec<u8>,
}
/// The network boundary this crate's pure logic is kept out from behind.
/// `server/src/routes.rs`'s module doc is the surface this drives.
pub trait Transport: Send + Sync {
/// One request/response call -- everything but the long-lived SSE GETs.
fn request(
&self,
method: &str,
@@ -61,10 +44,6 @@ pub trait Transport: Send + Sync {
body: Option<Body>,
) -> Result<RawResponse, ApiError>;
/// Opens `path` and answers a reader over the response body, for a
/// caller that reads it as a stream rather than all at once (the SSE
/// connections in [`crate::event_stream`]). Fails the same way
/// [`Transport::request`] does for a non-2xx response.
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
}
@@ -103,10 +82,6 @@ fn default_true() -> bool {
true
}
/// A client-core equivalent of `requestFromServer` plus the typed calls
/// built on it. Holds no state of its own beyond the transport -- the
/// session id or setup id a call is about is a parameter, per this
/// project's "ask for the least you need".
pub struct ApiClient<T: Transport> {
transport: T,
}
@@ -116,6 +91,14 @@ impl<T: Transport> ApiClient<T> {
Self { transport }
}
/// The transport underneath, for a caller that needs the raw SSE
/// stream (`event_stream::follow_session_events`) rather than one of
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
/// is the one that does.
pub fn transport(&self) -> &T {
&self.transport
}
fn json_request<R: for<'de> Deserialize<'de>>(
&self,
method: &str,
@@ -256,7 +239,7 @@ impl<T: Transport> ApiClient<T> {
/// A page of transcript history. `before` is the newest-first cursor
/// (server default is "the newest page" when absent, which a caller
/// gets by passing `None`); the events themselves are handed back as
/// [`event_model::SeqEvent`] via `crate::event_stream`'s parsing, kept
/// [`event_model::SeqEvent`] via `crate::client::event_stream`'s parsing, kept
/// out of this method's signature so a caller that only wants the raw
/// lines (for the transcript cache) is not forced to parse them.
pub fn fetch_transcript_page(
@@ -266,23 +249,58 @@ impl<T: Transport> ApiClient<T> {
limit: u32,
coalesce: bool,
) -> Result<Vec<Value>, ApiError> {
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
if let Some(before) = before {
path.push_str(&format!("&before={before}"));
}
if coalesce {
path.push_str("&coalesce=true");
}
self.json_request("GET", &path, None)
self.json_request(
"GET",
&transcript_path(session_id, before, limit, coalesce, None),
None,
)
}
pub fn fetch_transcript_lines(
&self,
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
after: Option<u64>,
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
let path = transcript_path(session_id, before, limit, coalesce, after);
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
raw.into_iter()
.map(|value| {
let line = value.get().to_string();
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
message: format!(
"the server sent a transcript line this build couldn't parse: {e}"
),
status: None,
})?;
Ok((line, event))
})
.collect()
}
}
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
/// poll). Verifies the server's leaf against a single pinned CA, the way
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
/// store -- the server's certificate is self-signed on purpose (see
/// `wg-app-link`).
fn transcript_path(
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
after: Option<u64>,
) -> String {
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
if let Some(before) = before {
path.push_str(&format!("&before={before}"));
}
if coalesce {
path.push_str("&coalesce=true");
}
if let Some(after) = after {
path.push_str(&format!("&after={after}"));
}
path
}
pub struct UreqTransport {
agent: ureq::Agent,
base_url: String,
@@ -290,8 +308,6 @@ pub struct UreqTransport {
}
impl UreqTransport {
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
/// exactly as read from `certs/ca.pem`.
pub fn new(
base_url: impl Into<String>,
token: impl Into<String>,
@@ -306,10 +322,6 @@ impl UreqTransport {
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls_config)
// Read the body ourselves on every status, the way
// `requestFromServer` does: the server's own error wording is
// in the body of a 4xx/5xx, and the default behaviour throws
// it away before this code can read it.
.http_status_as_error(false)
.timeout_connect(Some(std::time::Duration::from_secs(5)))
.build()
@@ -387,9 +399,6 @@ impl Transport for UreqTransport {
.get(&url)
.header("Authorization", &auth)
.header("Accept", "text/event-stream")
// No read timeout: between events there is nothing to read for
// as long as the thing being followed is idle, mirroring
// `EventStream.kt`'s `readTimeout = 0`.
.config()
.timeout_recv_response(None)
.build()
@@ -415,9 +424,6 @@ fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
}
}
/// The 401 wording matches `Api.kt`'s, since that message is instructions
/// for the reader rather than a diagnostic -- see this project's UI rule
/// about shortening a failure in one place rather than at each display site.
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
let detail = String::from_utf8_lossy(body).trim().to_string();
let message = if status == 401 {
@@ -441,8 +447,6 @@ mod tests {
use std::io::Cursor;
use std::sync::Mutex;
/// A transport with no network at all, for the pure-logic tests this
/// module can run without a server.
#[derive(Default)]
struct FakeTransport {
responses: Mutex<Vec<(String, String, RawResponse)>>,
@@ -503,7 +507,6 @@ mod tests {
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s1");
assert_eq!(sessions[0].setup_name, "desktop");
// Defaults for fields the server omits.
assert!(sessions[0].notify);
assert_eq!(sessions[0].model, None);
}
+319
View File
@@ -0,0 +1,319 @@
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
/// because an app built on the machine its server runs on pins the CA at
/// build time and needs nothing from the link; one built elsewhere -- the
/// iris Android client is cross-compiled in a VM and run against the
/// host's server -- has no other way to get it. A public certificate
/// rather than a secret, so it costs the link nothing but length.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnrolledServer {
pub host: String,
pub port: u16,
pub token: String,
#[serde(default)]
pub ca_pem: Option<String>,
}
impl EnrolledServer {
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
/// here, because that is what every consumer of it wants
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
/// at). A `ca` that does not decode fails the whole link rather than
/// enrolling a server with no trust anchor: the link said which
/// certificate to pin, and quietly not pinning it is the one outcome
/// nothing downstream could notice.
pub fn parse_link(link: &str) -> Result<Self, String> {
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
format!(
"'{link}' has no query string (expected \
aiapp://enroll?host=...&port=...&token=...)"
)
})?;
let mut host = None;
let mut port = None;
let mut token = None;
let mut ca = None;
for pair in query.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
let value = percent_decode(value);
match key {
"host" => host = Some(value),
"port" => port = Some(value),
"token" => token = Some(value),
"ca" => ca = Some(value),
_ => {}
}
}
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
let port: u16 = port_str
.parse()
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?;
Ok(Self {
host,
port,
token,
ca_pem,
})
}
pub fn base_url(&self) -> String {
format!("https://{}:{}", self.host, self.port)
}
}
fn pem_from_link_param(ca: &str) -> Result<String, String> {
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(ca.as_bytes())
.map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?;
let body = base64::engine::general_purpose::STANDARD.encode(&der);
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
for line in body.as_bytes().chunks(64) {
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
pem.push('\n');
}
pem.push_str("-----END CERTIFICATE-----\n");
Ok(pem)
}
/// Where one client keeps the enrollment it should not have to be told
/// about a second time. `dir` is the caller's, because that is the only
/// part that differs by platform -- see this module's doc.
pub struct EnrollmentStore {
dir: PathBuf,
}
impl EnrollmentStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn file(&self) -> PathBuf {
self.dir.join("enrollment.json")
}
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
let path = self.file();
let json = serde_json::to_vec_pretty(server)
.expect("EnrolledServer holds nothing that fails to serialise");
std::fs::write(&path, json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
let path = self.file();
match std::fs::read(&path) {
Ok(bytes) => {
let server = serde_json::from_slice(&bytes).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{} is not a valid enrollment ({e})", path.display()),
)
})?;
Ok(Some(server))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let Ok(byte) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
out.push(byte);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_host_port_and_token() {
let server =
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
.unwrap();
assert_eq!(
server,
EnrolledServer {
host: "127.0.0.1".to_string(),
port: 8547,
token: "abcDEF123".to_string(),
ca_pem: None,
}
);
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
}
#[test]
fn field_order_does_not_matter() {
let server =
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
.unwrap();
assert_eq!(server.host, "example.com");
assert_eq!(server.port, 443);
assert_eq!(server.token, "tok");
}
#[test]
fn a_percent_encoded_token_is_decoded() {
let server =
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
assert_eq!(server.token, "a+b/c");
}
#[test]
fn a_missing_field_is_named_in_the_error() {
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
assert!(
err.contains("token"),
"error should name the missing field: {err}"
);
}
#[test]
fn a_ca_in_the_link_comes_back_as_pem() {
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der);
let server =
EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}"))
.unwrap();
let pem = server.ca_pem.expect("the link carried a CA");
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}");
assert!(
pem.trim_end().ends_with("-----END CERTIFICATE-----"),
"{pem}"
);
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(
pem.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<String>()
)
.unwrap(),
der
);
}
#[test]
fn no_ca_parameter_is_none_not_an_error() {
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
assert_eq!(server.ca_pem, None);
}
#[test]
fn a_ca_that_does_not_decode_fails_the_link() {
let err =
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url")
.unwrap_err();
assert!(err.contains("ca"), "{err}");
}
#[test]
fn a_saved_enrollment_reads_back_the_same() {
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
let server = EnrolledServer {
host: "127.0.0.1".to_string(),
port: 8547,
token: "tok".to_string(),
ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()),
};
store.save(&server).unwrap();
assert_eq!(store.load().unwrap(), Some(server));
}
#[test]
fn nothing_saved_yet_is_none_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
}
#[test]
fn an_enrollment_without_a_ca_still_loads() {
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
std::fs::create_dir_all(dir.path()).unwrap();
std::fs::write(
dir.path().join("enrollment.json"),
br#"{"host":"h","port":1,"token":"t"}"#,
)
.unwrap();
assert_eq!(store.load().unwrap().unwrap().ca_pem, None);
}
#[test]
#[cfg(unix)]
fn the_saved_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
store
.save(&EnrolledServer {
host: "h".to_string(),
port: 1,
token: "t".to_string(),
ca_pem: None,
})
.unwrap();
let mode = std::fs::metadata(dir.path().join("enrollment.json"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn a_corrupt_file_is_named_in_the_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap();
let err = EnrollmentStore::new(dir.path()).load().unwrap_err();
assert!(err.to_string().contains("enrollment.json"));
}
#[test]
fn a_non_numeric_port_is_named_in_the_error() {
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
assert!(
err.contains("port"),
"error should name the offending field: {err}"
);
}
}
+74
View File
@@ -0,0 +1,74 @@
/// A tool's timeout arrives as `480000`, which nobody reads as eight
/// minutes. The rule has two halves, because a short span and a long one
/// are read for different things. Under a minute the question is "roughly
/// how long", so only the largest unit is shown and a fraction carries the
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
/// units are left out rather than written as zero.
pub fn format_millis(ms: i64) -> String {
if ms < 0 {
return format!("-{}", format_millis(-ms));
}
if ms < 1000 {
return format!("{ms}ms");
}
if ms < 60_000 {
let tenths = (ms + 50) / 100;
let (whole, rest) = (tenths / 10, tenths % 10);
return if rest == 0 {
format!("{whole}s")
} else {
format!("{whole}.{rest}s")
};
}
let seconds = ms / 1000;
[
("d", seconds / 86_400),
("h", seconds / 3600 % 24),
("m", seconds / 60 % 60),
("s", seconds % 60),
]
.iter()
.filter(|(_, n)| *n > 0)
.map(|(unit, n)| format!("{n}{unit}"))
.collect::<Vec<_>>()
.join(" ")
}
pub fn format_millis_text(text: &str) -> String {
match text.trim().parse::<i64>() {
Ok(ms) => format_millis(ms),
Err(_) => text.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn under_a_minute_is_the_largest_unit_alone() {
assert_eq!(format_millis(30), "30ms");
assert_eq!(format_millis(999), "999ms");
assert_eq!(format_millis(1000), "1s");
assert_eq!(format_millis(2500), "2.5s");
assert_eq!(format_millis(2460), "2.5s");
assert_eq!(format_millis(59_900), "59.9s");
}
#[test]
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
assert_eq!(format_millis(480_000), "8m");
assert_eq!(format_millis(60_000), "1m");
assert_eq!(format_millis(90_000), "1m 30s");
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
assert_eq!(format_millis(432_240_000), "5d 4m");
}
#[test]
fn only_a_whole_number_of_milliseconds_is_rewritten() {
assert_eq!(format_millis_text(" 480000 "), "8m");
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
assert_eq!(format_millis_text(""), "");
}
}
@@ -1,14 +1,9 @@
//! The SSE half of the API: one long-lived GET per open session screen,
//! replaying the transcript after a cursor and then following it live.
//! Ported from `app/.../EventStream.kt`; the framing itself is
//! [`crate::sse`].
use std::io::{BufRead, BufReader};
use event_model::SeqEvent;
use crate::api::{ApiError, Transport};
use crate::sse::SseReader;
use crate::client::api::{ApiError, Transport};
use crate::client::sse::SseReader;
/// The frame name the server uses to say a cursor was too far behind to
/// continue from. Must match `send_backlog` in `server/src/routes.rs`.
@@ -19,19 +14,8 @@ const RESET_EVENT: &str = "reset";
/// callbacks were for, as a single enum instead, since Rust has no
/// equivalent of handing three closures to one blocking call.
pub enum StreamItem {
/// The connection was accepted; the measured moment the stream is live
/// (see `EventStream.kt`'s doc on `onOpen` for why this, not the first
/// event, is what clears a previous failure on screen).
Open,
/// The cursor was too far behind to continue from: everything already
/// displayed is stale, and the events that follow are a fresh window.
/// Arrives before those events, so a caller that clears on it stays in
/// order.
Reset,
/// One event, as both the raw line the transcript cache stores and the
/// parsed [`SeqEvent`] the fold works from -- they have to be the same
/// line, so both travel together rather than being parsed twice from
/// two call sites.
Event { raw: String, event: SeqEvent },
}
@@ -59,7 +43,6 @@ pub fn follow_session_events(
let Some(frame) = reader.feed_line(&line) else {
continue;
};
// A named frame carries no payload and a data frame has no name.
if frame.name.as_deref() == Some(RESET_EVENT) {
if !on_item(StreamItem::Reset) {
return Ok(());
@@ -83,7 +66,7 @@ pub fn follow_session_events(
#[cfg(test)]
mod tests {
use super::*;
use crate::api::{Body, RawResponse};
use crate::client::api::{Body, RawResponse};
use std::io::Cursor;
struct FixtureTransport {
@@ -1,8 +1,3 @@
//! A language the highlighter can colour, and the data-driven [`Rules`] each
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
//! why nearly every language is a row of data read by one shared scanner,
//! with Markdown the one exception (`super::markdown`).
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -32,8 +27,6 @@ pub enum Language {
}
impl Language {
/// Every value, for the same exhaustiveness check the Kotlin test runs
/// (`Language.entries`).
pub const ALL: [Language; 22] = [
Language::C,
Language::Coffeescript,
@@ -60,24 +53,14 @@ impl Language {
];
}
/// What [`super::scan`] needs to know about one language -- data, not code,
/// so that adding a language is a row here rather than a branch anywhere.
#[derive(Debug, Clone, Default)]
pub struct Rules {
/// Words drawn as keywords. Only plain words; the scanner cannot reach
/// anything else.
pub keywords: HashSet<&'static str>,
/// Tokens that open a comment running to the end of the line.
pub line_comments: Vec<&'static str>,
/// Whether `line_comments` count only at the start of a word. The shells
/// need it: `$#`, `${#x}` and `a#b` are not comments.
pub line_comments_at_word_start: bool,
pub block_comment: Option<BlockComment>,
/// The string forms. The longest opener that matches wins, so `"""` is
/// tried before `"`.
pub quotes: Vec<Quote>,
pub attributes: Attributes,
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
pub raw_strings: bool,
/// Rust: `'` opens a character literal only when a backslash or one
/// character and a `'` follow. Otherwise it is a lifetime or a label.
@@ -91,8 +74,6 @@ pub struct BlockComment {
pub nests: bool,
}
/// One string form. `escapes` is whether a backslash escapes the closer
/// (and itself).
#[derive(Debug, Clone, Copy)]
pub struct Quote {
pub open: &'static str,
@@ -100,18 +81,13 @@ pub struct Quote {
pub escapes: bool,
}
/// What opens a metadata span, of the shapes that exist across these languages.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Attributes {
#[default]
None,
/// `@` and a word: Kotlin and Java annotations, Python decorators.
AtWord,
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
HashBracket,
/// `#` at the start of a line, to the end of it: the C preprocessor.
HashLine,
/// `[` at the start of a line through the matching `]`: a TOML table header.
LineBracket,
}
@@ -151,10 +127,6 @@ fn words(list: &'static str) -> HashSet<&'static str> {
list.split_whitespace().collect()
}
/// The rules for one language. A `match` rather than a lazily-built map --
/// there is no once-per-process cost worth paying for in a language table
/// this small, and it sidesteps the Kotlin version's own workaround for
/// property initialization order.
pub fn rules_for(language: Language) -> Rules {
match language {
Language::C => Rules {
@@ -180,8 +152,6 @@ pub fn rules_for(language: Language) -> Rules {
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
// `###` opens and closes a block comment and `#` opens a line one,
// which is why the scanner tries the block opener first.
Language::Coffeescript => Rules {
keywords: words(KEYWORDS_COFFEESCRIPT),
line_comments: vec!["#"],
@@ -318,7 +288,6 @@ pub fn rules_for(language: Language) -> Rules {
keywords: words(KEYWORDS_SHELL),
line_comments: vec!["#"],
line_comments_at_word_start: true,
// A shell's single quotes are literal: `'a\'` is not one string.
quotes: vec![
DOUBLE,
Quote {
@@ -373,16 +342,10 @@ pub fn rules_for(language: Language) -> Rules {
attributes: Attributes::AtWord,
..Default::default()
},
// Markdown has no token rules; see `super::markdown::scan_markdown`.
Language::Markdown => Rules::default(),
}
}
// The keyword sets. Every list below other than RON, TOML, fish and JSON
// came from dev.snipme:highlights 1.1.0 (Apache-2.0), the library the
// Kotlin scanner replaced, so that no fence which was coloured there turns
// plain here either.
const KEYWORDS_C: &str =
"auto break case char const continue default do double else enum extern float for goto if
int long register return short signed sizeof static struct switch typedef union unsigned
@@ -416,9 +379,6 @@ const KEYWORDS_DART: &str =
required rethrow return sealed set show static super switch this throw true try var void
when with while yield";
/// fish is not in the library at all, so its fences are drawn plain today.
/// The list is the shell's own words, which is what a fish fence is mostly
/// made of.
const KEYWORDS_FISH: &str =
"and begin break builtin case command continue else end exec for function if in not or
return switch while set echo test string math read source";
@@ -466,7 +426,6 @@ const KEYWORDS_PYTHON: &str =
for from global if import in is lambda nonlocal not or pass raise return try while with
yield";
/// RON is not in the library either; these are the words a RON file can hold.
const KEYWORDS_RON: &str = "true false Some None inf NaN";
const KEYWORDS_RUBY: &str =
@@ -495,8 +454,6 @@ const KEYWORDS_SWIFT: &str =
nonmutating optional override postfix precedence prefix Protocol required right set some Type
unowned weak willSet";
/// TOML is not in the library; `inf` and `nan` are values rather than
/// names, like the booleans.
const KEYWORDS_TOML: &str = "true false inf nan";
const KEYWORDS_TYPESCRIPT: &str =
@@ -518,8 +475,6 @@ pub fn fence_language(name: Option<&str>) -> Option<Language> {
.map(|(_, language)| *language)
}
/// The highlighter's language for a *file*, from its name.
///
/// The extension is the part after the *last* dot, which is what makes
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
/// extension, it has a name that starts with a dot. A name with no dot at
@@ -1,26 +1,8 @@
//! Markdown read into the spans that carry a colour -- a ```markdown fence
//! in a reply, and a `.md` file in the viewer. Ported from
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
//! scanner rather than a row of [`super::Rules`] (what a character means
//! depends on where it sits, not on what it is) and why an indented code
//! block is deliberately not recognised.
//!
//! Structure is read a line at a time and each line's prose left to right,
//! except the two decisions that are not: a fenced block is state carried
//! forward, and a table is found by its delimiter row, which comes after
//! the header it belongs to (the one place here that looks ahead).
use super::{Kind, Span};
/// The characters an unordered list may be bulleted with.
const BULLETS: &str = "-*+";
/// The characters a thematic break, or a setext heading's underline, can be
/// drawn with.
const RULE_MARKERS: &str = "-*_=";
/// The characters that can open emphasis, strong emphasis or a strikethrough.
const EMPHASIS: &str = "*_~";
/// Characters that end a bare URL wherever they appear, and ones only
/// trimmed off the end.
const URL_STOPS: &str = "<>\"'`|";
const URL_TRAILING: &str = ".,:;!?";
@@ -46,15 +28,10 @@ impl MarkdownScanner {
// The delimiter run that opened the fenced block we are inside, or
// None between them.
let mut fence: Option<Vec<char>> = None;
// Whether the row above was part of a table, which is what makes
// this one a body row.
let mut table = false;
loop {
let end = self.line_end(at);
if let Some(open) = fence.clone() {
// The content and the closing line alike: a fence is one
// block of code, and its own delimiters belong to it the
// way a string's quotes belong to the string.
self.emit(at, end, Kind::String);
if self.closes_fence(at, end, &open) {
fence = None;
@@ -76,7 +53,6 @@ impl MarkdownScanner {
self.spans
}
/// The end of the line beginning at `at`: the newline, or the end of the text.
fn line_end(&self, at: usize) -> usize {
self.code[at..]
.iter()
@@ -85,8 +61,6 @@ impl MarkdownScanner {
.unwrap_or(self.code.len())
}
/// One line that is not inside a fence, and whether the table it may be
/// part of is still open.
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
if self.table_delimiter(start, end) {
let indented = self.indented(start, end);
@@ -102,13 +76,11 @@ impl MarkdownScanner {
false
}
/// A line of nothing but pipes, dashes, alignment colons and space, with
/// one of each needed.
fn table_delimiter(&self, start: usize, end: usize) -> bool {
let mut dashes = false;
let mut pipes = false;
for at in self.indented(start, end)..end {
match self.code[at] {
for c in &self.code[self.indented(start, end)..end] {
match c {
'-' => dashes = true,
'|' => pipes = true,
':' | ' ' | '\t' => {}
@@ -132,7 +104,6 @@ impl MarkdownScanner {
false
}
/// A table row: the pipes are the structure, and what is between them is prose.
fn table_row(&mut self, start: usize, end: usize) {
let mut at = self.indented(start, end);
let mut cell = at;
@@ -151,7 +122,6 @@ impl MarkdownScanner {
self.inline(cell, end);
}
/// Spans, coalesced with the one before when they touch and agree.
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
if end <= start {
return;
@@ -166,7 +136,6 @@ impl MarkdownScanner {
self.spans.push(Span { start, end, kind });
}
/// The first character of the line at or after `start` that is not indentation.
fn indented(&self, start: usize, end: usize) -> usize {
let mut at = start;
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
@@ -175,8 +144,6 @@ impl MarkdownScanner {
at
}
/// The run of backticks or tildes that could open or close a fence on
/// this line, or `None`.
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
let at = self.indented(start, end);
if at == end {
@@ -193,20 +160,14 @@ impl MarkdownScanner {
if run - at >= 3 { Some((at, run)) } else { None }
}
/// Draws an opening fence line and answers its delimiter, or `None` if
/// this is not one.
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
let (run_start, run_end) = self.fence_run(start, end)?;
self.emit(run_start, run_end, Kind::String);
// The info word is what the fence is a fence *of*, which is
// metadata about the block rather than part of it.
let indented = self.indented(run_end, end);
self.emit(indented, end, Kind::Metadata);
Some(self.code[run_start..run_end].to_vec())
}
/// Whether this line closes a fence opened by `open`: the same
/// character, at least as many of them, and nothing else on the line.
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
let Some((run_start, run_end)) = self.fence_run(start, end) else {
return false;
@@ -217,12 +178,8 @@ impl MarkdownScanner {
self.indented(run_end, end) == end
}
/// One ordinary line: what its opening characters make it, and then its prose.
fn structure(&mut self, start: usize, end: usize) {
let mut at = start;
// Quote markers come before everything else and can be several
// deep, and what follows one is an ordinary line again -- a heading
// inside a quote is still a heading.
while at < end && self.code[at] == '>' {
at += 1;
self.emit(at - 1, at, Kind::Mark);
@@ -238,8 +195,6 @@ impl MarkdownScanner {
self.inline(text_start, end);
}
/// `#` to `######` and a space. Without the space it is a word
/// beginning with a hash.
fn heading(&mut self, start: usize, end: usize) -> bool {
let mut at = start;
while at < end && self.code[at] == '#' {
@@ -256,15 +211,13 @@ impl MarkdownScanner {
true
}
/// A line made of one repeated rule character and nothing else.
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
let marker = self.code[start];
if !RULE_MARKERS.contains(marker) {
return false;
}
let mut seen = 0usize;
for at in start..end {
let c = self.code[at];
for &c in &self.code[start..end] {
if c == marker {
seen += 1;
} else if !c.is_whitespace() {
@@ -278,8 +231,6 @@ impl MarkdownScanner {
true
}
/// Draws a list marker if the line opens with one, and answers where
/// the item's text starts.
fn bullet(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start];
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
@@ -305,16 +256,11 @@ impl MarkdownScanner {
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
}
/// The inline forms, left to right. Every branch answers a position
/// strictly after `start` of its call, so this terminates.
fn inline(&mut self, start: usize, end: usize) {
let mut at = start;
while at < end {
let c = self.code[at];
at = if c == '\\' {
// A backslash takes the character after it out of the
// running entirely, which is how `\*` stays an asterisk
// rather than opening emphasis.
at + 2
} else if c == '`' {
self.code_span(at, end)
@@ -332,7 +278,6 @@ impl MarkdownScanner {
}
}
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
fn code_span(&mut self, start: usize, end: usize) -> usize {
let mut open = start;
while open < end && self.code[open] == '`' {
@@ -355,11 +300,9 @@ impl MarkdownScanner {
}
at = close;
}
// Nothing closes it on this line, so those were ordinary backticks.
open
}
/// `[text](destination)`, and the same with a leading `!` for an image.
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
let mut depth = 0i32;
let mut close = bracket;
@@ -398,8 +341,6 @@ impl MarkdownScanner {
paren + 1
}
/// `<https://example.com>` and `<name@example.com>`, drawn as the
/// destination they are.
fn autolink(&mut self, start: usize, end: usize) -> usize {
let mut at = start + 1;
let mut addressed = false;
@@ -423,8 +364,6 @@ impl MarkdownScanner {
start + 1
}
/// A bare `scheme://...` written in prose, or `None` if one does not
/// start here.
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
if start > 0 && is_word(self.code[start - 1]) {
return None;
@@ -466,8 +405,6 @@ impl MarkdownScanner {
Some(at)
}
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
/// all.
fn emphasis(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start];
let mut open = start;
@@ -1,24 +1,3 @@
//! `code` read once, left to right, into the spans that carry a colour.
//! Ported from `app/.../Highlighter.kt`.
//!
//! One pass with a small state -- in a comment, in a string, or in ordinary
//! code -- rather than a locator per token kind over the whole text, which
//! is what the library this replaced did and is why it found comments
//! before it knew the language: a `#` inside a shell string, a `//` inside
//! a URL and a block-comment opener inside a shell glob each commented out
//! the rest of a line that was nothing of the sort.
//!
//! Every span is produced by advancing an index forward, so the result is
//! ordered, non-overlapping and inside the code by construction. Nothing
//! here panics: an unterminated string or comment runs to the end of the
//! code, which is also what it looks like while a fence is still being
//! written.
//!
//! **Indices are char offsets, not byte offsets** -- the scanner works over
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
//! back into the text it covers.
pub mod languages;
pub mod markdown;
@@ -26,7 +5,6 @@ pub use languages::{
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
};
/// What a span of code is, in the terms a palette has a colour for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind {
Keyword,
@@ -38,7 +16,6 @@ pub enum Kind {
Mark,
}
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start: usize,
@@ -70,8 +47,6 @@ pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
Scanner::new(code, rules).run()
}
/// Characters coloured as punctuation, and as marks. Both sets are the ones
/// the library this replaced used.
const PUNCTUATION: &str = ",.:;";
const MARKS: &str = "()={}<>-+[]|&";
@@ -94,8 +69,6 @@ impl<'a> Scanner<'a> {
fn run(mut self) -> Vec<Span> {
while self.at < self.code.len() {
// Every branch that answers true has advanced `self.at`, so
// this terminates.
let consumed = self.block_comment()
|| self.line_comment()
|| self.raw_string()
@@ -126,15 +99,12 @@ impl<'a> Scanner<'a> {
starts_with_at(&self.code, self.at, token)
}
/// Whether a line comment token here opens one; see
/// [`Rules::line_comments_at_word_start`].
fn at_word_start(&self) -> bool {
self.at == 0
|| self.code[self.at - 1].is_whitespace()
|| ";|&(".contains(self.code[self.at - 1])
}
/// Whether only whitespace stands between the start of this line and here.
fn at_line_start(&self) -> bool {
let mut back = self.at as isize - 1;
while back >= 0 && self.code[back as usize] != '\n' {
@@ -152,8 +122,6 @@ impl<'a> Scanner<'a> {
}
}
/// From an open bracket through the one that matches it, or to the end
/// if none does.
fn advance_to_matching_bracket(&mut self) {
let mut depth = 0i32;
while self.at < self.code.len() {
@@ -180,9 +148,6 @@ impl<'a> Scanner<'a> {
self.at += comment.open.chars().count();
let mut depth = 1i32;
while self.at < self.code.len() && depth > 0 {
// The closer is tried first so that a language whose two
// delimiters are the same string -- CoffeeScript's `###` --
// closes rather than nesting forever.
if self.starts(comment.close) {
depth -= 1;
self.at += comment.close.chars().count();
@@ -210,7 +175,6 @@ impl<'a> Scanner<'a> {
true
}
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
fn raw_string(&mut self) -> bool {
if !self.rules.raw_strings {
return false;
@@ -267,8 +231,6 @@ impl<'a> Scanner<'a> {
}
fn string(&mut self) -> bool {
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
// than an empty string followed by a quote.
let mut quote: Option<Quote> = None;
for candidate in &self.rules.quotes {
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
@@ -346,9 +308,6 @@ impl<'a> Scanner<'a> {
true
}
/// A number is a run starting with a digit and carrying on through
/// letters, digits, `_` and `.` -- which covers `0xFF`, `1_000`, `1u32`
/// and `3.14` without a grammar for any of them.
fn number(&mut self) -> bool {
if !self.code[self.at].is_ascii_digit() {
return false;
@@ -403,7 +362,6 @@ fn is_word_part(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Whether `code[at..]` starts with `token`, both read as chars.
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
let token: Vec<char> = token.chars().collect();
if at + token.len() > code.len() {
@@ -412,8 +370,6 @@ fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
code[at..at + token.len()] == token[..]
}
/// The first index at or after `from` where `code` contains `needle`, or
/// `None`.
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
if needle.is_empty() || from > code.len() {
return None;
@@ -640,10 +596,6 @@ mod tests {
}
}
/// The scanner must never panic and must never answer a span the code
/// does not contain: the library this replaced answered a reversed
/// range here, which crashed a card, and a fence still being written is
/// an unterminated string or comment on every keystroke.
#[test]
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
let nasty = [
+637
View File
@@ -0,0 +1,637 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// Both bounds apply -- whichever bites first -- because the two failure
/// modes are different: a flood of short lines exhausts the count, and one
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
/// only by bytes can be emptied by a single line.
pub const DEFAULT_MAX_LINES: usize = 2000;
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
pub seq: u64,
/// Milliseconds since the unix epoch, from the app's own clock. The
/// app's rather than the receiver's: a line is timestamped when it
/// happened, and an upload can be minutes later or never.
pub at_ms: u64,
pub level: log::Level,
pub target: String,
pub message: String,
}
impl LogLine {
fn weight(&self) -> usize {
self.target.len() + self.message.len() + 32
}
/// `12:34:56.789 INFO iris::android: the message`, the shape a
/// person skims. Time of day only -- the date is in the report's own
/// header, and a ring never spans one.
pub fn format(&self) -> String {
format!(
"{} {:<5} {}: {}",
clock_time(self.at_ms),
self.level,
self.target,
self.message
)
}
}
fn clock_time(at_ms: u64) -> String {
let ms = at_ms % 1000;
let secs_of_day = (at_ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
ms
)
}
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
/// the app down for.
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Debug)]
struct Inner {
lines: VecDeque<LogLine>,
bytes: usize,
max_lines: usize,
max_bytes: usize,
next_seq: u64,
dropped: u64,
}
#[derive(Debug, Clone)]
pub struct LogRing(Arc<Mutex<Inner>>);
impl LogRing {
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
assert!(
max_lines > 0 && max_bytes > 0,
"a ring with no room holds nothing"
);
Self(Arc::new(Mutex::new(Inner {
lines: VecDeque::new(),
bytes: 0,
max_lines,
max_bytes,
next_seq: 0,
dropped: 0,
})))
}
pub fn with_defaults() -> Self {
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
}
/// A poisoned lock is a bug in a panicking logger, not a reason to
/// take the app down a second time -- the ring is a diagnostic, and
/// losing it must not be worse than the fault it was recording.
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
let mut guard = match self.0.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
f(&mut guard)
}
pub fn push(&self, level: log::Level, target: &str, message: String) {
self.with(|inner| {
let line = LogLine {
seq: inner.next_seq,
at_ms: now_ms(),
level,
target: target.to_string(),
message,
};
inner.next_seq += 1;
inner.bytes += line.weight();
inner.lines.push_back(line);
// `!is_empty()` rather than `len() > 1`: one line larger than
// the whole byte bound is kept, because dropping it would
// leave the ring silently empty while lines were arriving.
while inner.lines.len() > inner.max_lines
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
{
if let Some(evicted) = inner.lines.pop_front() {
inner.bytes -= evicted.weight();
inner.dropped += 1;
}
}
})
}
pub fn snapshot(&self) -> Vec<LogLine> {
self.with(|inner| inner.lines.iter().cloned().collect())
}
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
self.with(|inner| {
let lines: Vec<LogLine> = inner
.lines
.iter()
.filter(|line| line.seq >= seq)
.cloned()
.collect();
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
(lines, next)
})
}
pub fn len(&self) -> usize {
self.with(|inner| inner.lines.len())
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn dropped(&self) -> u64 {
self.with(|inner| inner.dropped)
}
/// The sequence number of the newest line held, or `None` for a ring
/// nothing has been written to.
///
/// What a reader needs to notice that this process **restarted**: the
/// ring is in memory, so a new process starts again at zero, and a
/// reader holding a cursor from the previous one would otherwise ask
/// for lines after a number nothing will reach for hours and see
/// nothing at all -- silently, which is worse than seeing the log
/// begin again. Answering `None` rather than 0 for an empty ring is
/// the same distinction [`Self::summary`] draws: "nothing has been
/// logged" is not a sequence number.
pub fn newest_seq(&self) -> Option<u64> {
self.with(|inner| inner.lines.back().map(|line| line.seq))
}
/// When the newest line was written, in unix milliseconds, or `None`
/// for a ring nothing has been written to.
pub fn last_at_ms(&self) -> Option<u64> {
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
}
pub fn to_text(&self) -> String {
self.snapshot()
.iter()
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n")
}
/// The newest `max_lines` lines, formatted, or `None` if the ring is
/// locked at this instant.
///
/// For the one caller that must not block: **the panic hook**. A panic
/// raised while this ring's own lock was held -- an allocation failing
/// inside [`Self::push`], an assertion in a `log::Log` on the way here
/// -- would deadlock the hook against the thread that is panicking,
/// and the process would hang instead of aborting, with nothing
/// written anywhere. Losing the context lines is the right trade
/// against that, and `None` says which happened rather than looking
/// like an empty log.
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
let guard = match self.0.try_lock() {
Ok(guard) => guard,
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => return None,
};
let lines = &guard.lines;
let from = lines.len().saturating_sub(max_lines);
Some(
lines
.iter()
.skip(from)
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n"),
)
}
/// One line for a diagnostics pane: how much is held, how much was
/// dropped, and when the last line arrived. "no lines yet" is its own
/// wording rather than a count of zero with a made-up time, because
/// "nothing has been logged" and "logging is not running" would
/// otherwise look the same.
pub fn summary(&self) -> String {
let (len, dropped, last) = self.with(|inner| {
(
inner.lines.len(),
inner.dropped,
inner.lines.back().map(|line| line.at_ms),
)
});
match last {
None => "app log: no lines yet".to_string(),
Some(at) => {
let dropped = if dropped > 0 {
format!(", {dropped} dropped")
} else {
String::new()
};
format!(
"app log: {len} lines held{dropped}, last {}",
clock_time(at)
)
}
}
}
}
/// Whether a target belongs to this app or to `iris` rather than to a
/// dependency -- `starts_with` guarded by an
/// exact match or a `::` so an unrelated crate that merely begins with the
/// same letters (there is no such crate today, but the check should not
/// rely on that) is never mistaken for one of ours.
fn is_own_target(target: &str) -> bool {
target == "iris"
|| target.starts_with("iris::")
|| target == "ai_app"
|| target.starts_with("ai_app::")
}
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
/// asked for, applied once here rather than at each `debug!` call site:
/// Info and above always ring, from anything, because a real warning or
/// error from a dependency is worth keeping. Debug and Trace ring only
/// from this app's own targets, and only while tracing is switched on --
/// otherwise `naga::front`/`wgpu_core`/`jni` log at Debug unconditionally
/// (the process logger's own level, set once at install and unrelated to
/// tracing), which is what filled the ring with 1339 lines of it and
/// dropped 4050 more before this existed. `iris`'s own Debug lines already
/// self-gate on `iris::diagnostics::trace_enabled` at their call sites
/// (commit 992c472); this is the backstop for lines this crate does not
/// control.
fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
level <= log::Level::Info || (trace_enabled && is_own_target(target))
}
pub struct RingLogger {
ring: LogRing,
inner: Box<dyn log::Log>,
trace_enabled: fn() -> bool,
}
impl RingLogger {
pub fn new(ring: LogRing, inner: Box<dyn log::Log>, trace_enabled: fn() -> bool) -> Self {
Self {
ring,
inner,
trace_enabled,
}
}
}
impl log::Log for RingLogger {
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
if ring_accepts(record.level(), record.target(), (self.trace_enabled)()) {
self.ring
.push(record.level(), record.target(), record.args().to_string());
}
if self.inner.enabled(record.metadata()) {
self.inner.log(record);
}
}
fn flush(&self) {
self.inner.flush();
}
}
/// Fails only if a logger is already installed, which is a programmer
/// error (two initialisation paths) rather than a recoverable condition --
/// the caller is named in the error so it is findable.
pub fn install(
ring: LogRing,
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
trace_enabled: fn() -> bool,
) -> Result<(), log::SetLoggerError> {
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner, trace_enabled)))?;
log::set_max_level(max_level);
Ok(())
}
/// **A deliberate process-global, where this project's rules otherwise say
/// pass context explicitly.** What is being modelled is already one: `log`
/// has exactly one backend per process, set once, and every `log::info!`
/// anywhere in the binary goes to it. A ring handed around as a parameter
/// would be a *second* answer to "which lines exist" -- the report would
/// show one ring while the logger filled another, and which one a caller
/// got would depend on how far down the call tree it was. The tests above
/// all use their own [`LogRing`], so nothing here needs this to be
/// testable.
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
pub fn process_ring() -> &'static LogRing {
PROCESS_RING.get_or_init(LogRing::with_defaults)
}
pub fn install_process_logger(
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
trace_enabled: fn() -> bool,
) -> Result<(), log::SetLoggerError> {
install(process_ring().clone(), inner, max_level, trace_enabled)
}
#[cfg(test)]
mod tests {
use super::*;
use log::Level;
fn fill(ring: &LogRing, count: usize) {
for n in 0..count {
ring.push(Level::Info, "test", format!("line {n}"));
}
}
#[test]
fn lines_come_back_oldest_first() {
let ring = LogRing::new(10, 1 << 20);
fill(&ring, 3);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 0", "line 1", "line 2"]);
}
#[test]
fn the_line_bound_drops_the_oldest_and_says_how_many() {
let ring = LogRing::new(3, 1 << 20);
fill(&ring, 5);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
assert_eq!(ring.len(), 3);
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
}
#[test]
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
let ring = LogRing::new(1000, 300);
for n in 0..10 {
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
}
assert!(
ring.len() < 10,
"the byte bound evicted: {} held",
ring.len()
);
assert!(ring.dropped() > 0);
assert!(
ring.snapshot().last().unwrap().message.starts_with('9'),
"and it evicted from the old end"
);
}
#[test]
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
let ring = LogRing::new(100, 64);
ring.push(Level::Error, "t", "y".repeat(5000));
assert_eq!(ring.len(), 1);
assert_eq!(ring.dropped(), 0);
}
#[test]
fn sequence_numbers_only_increase_and_survive_eviction() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 5);
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
}
#[test]
fn since_returns_only_what_is_new_and_the_next_cursor() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 3);
let (first, cursor) = ring.since(0);
assert_eq!(first.len(), 3);
assert_eq!(cursor, 3);
let (none, cursor) = ring.since(cursor);
assert!(none.is_empty(), "nothing new yet");
assert_eq!(cursor, 3, "and the cursor does not move");
ring.push(Level::Warn, "test", "later".into());
let (more, cursor) = ring.since(cursor);
assert_eq!(more.len(), 1);
assert_eq!(more[0].message, "later");
assert_eq!(cursor, 4);
}
#[test]
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
let ring = LogRing::new(100, 1 << 20);
assert_eq!(ring.newest_seq(), None, "an empty ring has no newest line");
fill(&ring, 5);
assert_eq!(ring.newest_seq(), Some(4));
let restarted = LogRing::new(100, 1 << 20);
fill(&restarted, 1);
assert_eq!(
restarted.newest_seq(),
Some(0),
"a fresh ring starts again, which is exactly what a reader has to notice"
);
}
#[test]
fn reading_does_not_consume() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 2);
let (sent, _) = ring.since(0);
assert_eq!(sent.len(), 2);
assert_eq!(ring.len(), 2, "the report still has them after an upload");
assert_eq!(ring.to_text().lines().count(), 2);
}
#[test]
fn try_tail_text_gives_the_newest_lines_with_no_header() {
let ring = LogRing::new(1000, 1 << 20);
fill(&ring, 200);
let tail = ring.try_tail_text(80).expect("nothing holds the lock");
let lines: Vec<&str> = tail.lines().collect();
assert_eq!(lines.len(), 80, "the cap, and no header: this is a file");
assert!(lines[0].ends_with("line 120"), "{}", lines[0]);
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
}
#[test]
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
let ring = LogRing::new(10, 1 << 20);
fill(&ring, 3);
let held = ring.0.lock().expect("fresh ring");
assert_eq!(ring.try_tail_text(80), None);
drop(held);
assert!(ring.try_tail_text(80).is_some());
}
#[test]
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
let ring = LogRing::with_defaults();
assert_eq!(ring.summary(), "app log: no lines yet");
assert_eq!(ring.last_at_ms(), None);
assert!(ring.is_empty());
}
#[test]
fn the_summary_names_dropped_lines_only_when_there_are_some() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 2);
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
fill(&ring, 2);
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
}
#[test]
fn a_line_formats_as_time_level_target_message() {
let line = LogLine {
seq: 0,
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
level: Level::Info,
target: "iris::android".into(),
message: "surface created".into(),
}
.format();
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
}
#[test]
fn the_ring_logger_forwards_to_the_inner_logger() {
use log::Log;
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
impl Log for Collect {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= self.1
}
fn log(&self, record: &log::Record) {
self.0.lock().unwrap().push(record.args().to_string());
}
fn flush(&self) {}
}
let seen = Arc::new(Mutex::new(Vec::new()));
let ring = LogRing::with_defaults();
let logger = RingLogger::new(
ring.clone(),
Box::new(Collect(seen.clone(), Level::Info)),
|| true,
);
logger.log(
&log::Record::builder()
.args(format_args!("kept"))
.level(Level::Info)
.target("iris::test")
.build(),
);
logger.log(
&log::Record::builder()
.args(format_args!("filtered"))
.level(Level::Debug)
.target("iris::test")
.build(),
);
assert_eq!(
*seen.lock().unwrap(),
["kept"],
"the inner logger's own filter still applies"
);
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(
held,
["kept", "filtered"],
"own-target debug still rings while tracing is on"
);
}
#[test]
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
use log::Log;
struct Discard;
impl Log for Discard {
fn enabled(&self, _: &log::Metadata) -> bool {
true
}
fn log(&self, _: &log::Record) {}
fn flush(&self) {}
}
let ring = LogRing::with_defaults();
let logger = RingLogger::new(ring.clone(), Box::new(Discard), || true);
logger.log(
&log::Record::builder()
.args(format_args!("naga debug spam"))
.level(Level::Debug)
.target("naga::front")
.build(),
);
logger.log(
&log::Record::builder()
.args(format_args!("naga warning"))
.level(Level::Warn)
.target("wgpu_core::device")
.build(),
);
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(
held,
["naga warning"],
"Info-and-above always rings; foreign Debug never does"
);
}
#[test]
fn ring_accepts_is_own_target_debug_only_while_tracing() {
assert!(
ring_accepts(Level::Info, "wgpu_core::device", false),
"Info+ from anything, tracing off"
);
assert!(
ring_accepts(Level::Warn, "jni", true),
"Info+ from anything, tracing on"
);
assert!(
!ring_accepts(Level::Debug, "jni", true),
"foreign Debug, tracing on: still excluded"
);
assert!(
!ring_accepts(Level::Debug, "iris::sense", false),
"own Debug, tracing off: excluded"
);
assert!(
ring_accepts(Level::Debug, "iris::sense", true),
"own Debug, tracing on: included"
);
assert!(
ring_accepts(Level::Trace, "ai_app::api", true),
"own Trace, tracing on: included"
);
}
#[test]
fn is_own_target_matches_the_crate_or_its_modules_only() {
assert!(is_own_target("iris"));
assert!(is_own_target("iris::sense"));
assert!(is_own_target("ai_app"));
assert!(is_own_target("ai_app::log_ring"));
assert!(!is_own_target("iris_something_else"));
assert!(!is_own_target("naga::front"));
assert!(!is_own_target("jni"));
}
}
+241
View File
@@ -0,0 +1,241 @@
use pulldown_cmark::{Event, Options, Parser, Tag};
/// What a block is, for a renderer that wants to style or space blocks
/// differently. `Other` is deliberately present rather than a panic or a
/// silent fallback to `Paragraph`: markdown has more block kinds than this
/// list and more get added, and a renderer treating an unknown one as
/// prose is right, but it should be able to *tell* that is what it is
/// doing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockKind {
Paragraph,
Heading,
Code,
List,
Table,
Quote,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Block {
pub kind: BlockKind,
pub source: String,
}
fn kind_of(tag: &Tag) -> BlockKind {
match tag {
Tag::Paragraph => BlockKind::Paragraph,
Tag::Heading { .. } => BlockKind::Heading,
Tag::CodeBlock(_) => BlockKind::Code,
Tag::List(_) => BlockKind::List,
Tag::Table(_) => BlockKind::Table,
Tag::BlockQuote(_) => BlockKind::Quote,
_ => BlockKind::Other,
}
}
fn options() -> Options {
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
}
pub fn split_blocks(src: &str) -> Vec<Block> {
let mut out: Vec<Block> = Vec::new();
let mut depth = 0usize;
let mut kind = BlockKind::Other;
for (event, range) in Parser::new_ext(src, options()).into_offset_iter() {
match event {
Event::Start(tag) => {
if depth == 0 {
kind = kind_of(&tag);
}
depth += 1;
}
Event::End(_) => {
depth -= 1;
if depth == 0 {
push(&mut out, kind, &src[range]);
}
}
_ => {
if depth == 0 {
push(&mut out, BlockKind::Other, &src[range]);
}
}
}
}
out
}
fn push(out: &mut Vec<Block>, kind: BlockKind, source: &str) {
let source = source.trim_end();
if source.is_empty() {
return;
}
out.push(Block {
kind,
source: source.to_string(),
});
}
/// How many leading blocks of `old` and `new` are identical -- what a
/// caller may keep the laid-out widgets for. See the module doc for why
/// this is a comparison rather than an assumption.
pub fn common_prefix(old: &[Block], new: &[Block]) -> usize {
old.iter().zip(new).take_while(|(a, b)| a == b).count()
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(src: &str) -> Vec<BlockKind> {
split_blocks(src).into_iter().map(|b| b.kind).collect()
}
#[test]
fn a_message_splits_into_its_top_level_blocks() {
let src = "# Title\n\nFirst para.\n\n```rust\nfn main() {}\n```\n\n- a\n- b\n";
assert_eq!(
kinds(src),
vec![
BlockKind::Heading,
BlockKind::Paragraph,
BlockKind::Code,
BlockKind::List
]
);
let blocks = split_blocks(src);
assert_eq!(blocks[1].source, "First para.");
assert_eq!(blocks[2].source, "```rust\nfn main() {}\n```");
}
#[test]
fn blank_input_has_no_blocks() {
assert!(split_blocks("").is_empty());
assert!(split_blocks(" \n\n ").is_empty());
}
#[test]
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
let after = split_blocks("# Title\n\nFirst para.\n\nSecond paragraph now.");
assert_eq!(common_prefix(&before, &after), 2);
assert_eq!(before.len(), 3);
assert_eq!(after.len(), 3);
assert_ne!(before[2], after[2]);
}
#[test]
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
let before = split_blocks("First para.\n\nSecond para.");
let after = split_blocks("First para.\n\nSecond para.\n\nThird");
assert_eq!(common_prefix(&before, &after), 2);
assert_eq!(after.len(), 3);
}
#[test]
fn an_unterminated_fence_is_one_block_while_it_streams() {
for src in [
"Here:\n\n```rust\n",
"Here:\n\n```rust\nfn main() {\n",
"Here:\n\n```rust\nfn main() {\n println!(\"hi\");\n",
] {
assert_eq!(
kinds(src),
vec![BlockKind::Paragraph, BlockKind::Code],
"{src:?}"
);
}
}
#[test]
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
let before = split_blocks("Not a heading\n\nsecond");
let after = split_blocks("Not a heading\n\nsecond\n---");
assert_eq!(before[1].kind, BlockKind::Paragraph);
assert_eq!(after[1].kind, BlockKind::Heading);
assert_eq!(
common_prefix(&before, &after),
1,
"the rewritten block must not be reported as keepable"
);
}
#[test]
fn a_thematic_break_is_its_own_block() {
assert_eq!(
kinds("one\n\n---\n\ntwo"),
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
);
}
#[test]
fn the_transcripts_own_block_shapes_survive_a_split() {
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
assert_eq!(
kinds(fence_with_blanks),
vec![BlockKind::Paragraph, BlockKind::Code, BlockKind::Paragraph],
"a blank line inside a fence is not a block boundary"
);
assert_eq!(
kinds("```\n---\n```"),
vec![BlockKind::Code],
"a thematic break inside a fence is code, not a break"
);
assert_eq!(
kinds("- a\n - a1\n - a2\n- b"),
vec![BlockKind::List],
"a nested list is one top-level block"
);
assert_eq!(
kinds("## Heading\n```sh\nls\n```"),
vec![BlockKind::Heading, BlockKind::Code],
"a fence directly under a heading, with no blank line"
);
assert_eq!(
kinds("| a | b |\n|---|---|\n| 1 | 2 |"),
vec![BlockKind::Table]
);
assert_eq!(
kinds("> quoted\n> more\n\nplain"),
vec![BlockKind::Quote, BlockKind::Paragraph]
);
}
#[test]
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
let mut prev = Vec::new();
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
let now = split_blocks(&full[..end]);
let common = common_prefix(&prev, &now);
assert!(
prev.is_empty() || common + 1 >= prev.len(),
"at {end} bytes the split rewrote block {common} of {}, not just the last one:\n before={prev:#?}\nafter={now:#?}",
prev.len()
);
prev = now;
}
}
#[test]
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
let blocks = split_blocks(src);
assert_eq!(
blocks.iter().map(|b| b.kind).collect::<Vec<_>>(),
vec![BlockKind::Paragraph, BlockKind::Code]
);
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
}
#[test]
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
let before = split_blocks("Text.\n\n```\ncode\n");
let after = split_blocks("Text.\n\n```\ncode\n```");
assert_eq!(before.len(), after.len());
assert_eq!(common_prefix(&before, &after), 1);
assert_ne!(before[1], after[1]);
}
}
@@ -1,15 +1,17 @@
//! The app's pure logic, shared between the server and any Rust client --
//! see `docs/CLIENT_CORE.md` for what lives here and what does
//! not yet.
pub mod ansi;
pub mod api;
pub mod config;
pub mod durations;
pub mod event_stream;
pub mod highlight;
pub mod log_ring;
pub mod markdown_blocks;
pub mod notifications;
pub mod sse;
pub mod text_cap;
pub mod tool_summary;
pub mod transcript_cache;
pub mod transcript_fold;
pub mod transcript_source;
pub use event_model::*;
@@ -1,27 +1,17 @@
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
//! places, never both" describes. Ported from the parsing half of
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
//! ([`crate::sse`]) and the wire shape ([`SessionNotification`],
//! ([`crate::client::sse`]) and the wire shape ([`SessionNotification`],
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
//! `Notification`/`NotificationKind`).
//!
//! What is deliberately **not** here, because it is a decision rather than
//! logic: whether a given notification is shown at all (the session on
//! screen gets nothing), handed to the app as a banner, or posted to the
//! platform's own notification drawer. That three-way choice reads
//! process-wide state (what screen is open, whether the app is in front)
//! that has no meaning to a pure crate with no UI and no Android in it --
//! see `android-shell` for where it lives for this port.
use std::io::{BufRead, BufReader};
use serde::Deserialize;
use crate::api::{ApiError, Transport};
use crate::sse::SseReader;
use crate::client::api::{ApiError, Transport};
use crate::client::sse::SseReader;
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
/// `Notification` field for field.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNotification {
@@ -32,9 +22,6 @@ pub struct SessionNotification {
pub at: f64,
}
/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized
/// the same way, so this deserializes the wire's `"awaitingInput"` /
/// `"finished"` directly rather than through a string match.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NotificationKind {
@@ -93,7 +80,7 @@ pub fn follow_notifications(
#[cfg(test)]
mod tests {
use super::*;
use crate::api::{Body, RawResponse};
use crate::client::api::{Body, RawResponse};
use std::io::Cursor;
struct FixtureTransport {
@@ -1,15 +1,3 @@
//! Server-sent-events framing, ported from `app/.../Sse.kt`: `data:` and
//! `event:` lines accumulate until a blank line ends the frame, comments
//! start with `:`, and a frame is either named with no payload or a payload
//! with no name.
//!
//! Pure and line-at-a-time, unlike the Kotlin original which also owned the
//! socket: `server/routes.rs`'s SSE bodies are one event per line, so a
//! caller here feeds lines from wherever they came from (a real connection,
//! a test fixture) and gets frames back with no I/O of its own -- which is
//! what lets this be tested with no server, per RUST.md's "pure logic
//! first" for this crate.
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
@@ -17,10 +5,6 @@ pub struct Frame {
pub data: String,
}
/// Accumulates lines into [`Frame`]s. One instance per connection --
/// `feed_line` is called for every line the transport reads (with line
/// endings already stripped), and answers a frame when a blank line closes
/// one.
#[derive(Debug, Default)]
pub struct SseReader {
data: String,
@@ -32,8 +16,6 @@ impl SseReader {
Self::default()
}
/// Feeds one line (no trailing `\n`). Answers the frame this line
/// completed, if any.
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
if line.is_empty() {
if self.name.is_some() || !self.data.is_empty() {
@@ -50,7 +32,6 @@ impl SseReader {
} else if let Some(rest) = line.strip_prefix("event:") {
self.name = Some(rest.trim().to_string());
}
// `id:`, comments -- nothing to do.
None
}
}
+93
View File
@@ -0,0 +1,93 @@
/// The default bound on a verbatim block -- a tool call's input or its
/// output. Short, because this text is a machine's and the reader is
/// looking for one line of it.
pub const VERBATIM_LINES: usize = 80;
pub const VERBATIM_BYTES: usize = 4096;
pub const MESSAGE_LINES: usize = 200;
pub const MESSAGE_BYTES: usize = 16 * 1024;
const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0);
const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0);
/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line
/// count it was cut *from*; `None` when the whole of it fits.
pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> {
debug_assert!(
max_lines > 0 && max_bytes > 0,
"a cap of nothing shows an empty block and a 'Show all' for every value there is",
);
let by_lines = text
.char_indices()
.filter(|(_, c)| *c == '\n')
.nth(max_lines - 1)
.map(|(i, _)| i);
let by_bytes = (text.len() > max_bytes).then(|| {
let mut end = max_bytes;
// Back up to a character boundary: a cut inside a multi-byte
// character panics on the slice below, and a transcript is full of
// them.
while !text.is_char_boundary(end) {
end -= 1;
}
end
});
let cut = match (by_lines, by_bytes) {
(Some(a), Some(b)) => a.min(b),
(a, b) => a.or(b)?,
};
Some((&text[..cut], text.lines().count()))
}
pub fn show_all_label(lines: usize) -> String {
format!("Show all {lines} lines")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_under_both_bounds_is_not_cut() {
assert_eq!(cut("one\ntwo\nthree", 80, 4096), None);
}
#[test]
fn the_line_bound_cuts_at_a_line_boundary() {
let text = "a\nb\nc\nd\n";
let (shown, lines) = cut(text, 2, 4096).expect("four lines is over a bound of two");
assert_eq!(shown, "a\nb");
assert_eq!(
lines, 4,
"the count is the whole text's, not the shown part's"
);
}
#[test]
fn the_byte_bound_cuts_one_long_line() {
let text = "x".repeat(5000);
let (shown, lines) = cut(&text, 80, 4096).expect("5000 bytes is over a bound of 4096");
assert_eq!(shown.len(), 4096);
assert_eq!(lines, 1);
}
#[test]
fn the_tighter_of_the_two_bounds_wins() {
let text = "aaaa\n".repeat(100);
let (shown, _) = cut(&text, 80, 100).expect("over both");
assert_eq!(shown.len(), 100, "the byte bound is the tighter one here");
let (shown, _) = cut(&text, 4, 4096).expect("over the line bound");
assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa");
}
#[test]
fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() {
let text = "é".repeat(100);
let (shown, _) = cut(&text, 80, 11).expect("200 bytes is over a bound of 11");
assert_eq!(
shown,
"é".repeat(5),
"11 bytes lands mid-character; 10 is the cut"
);
}
}
+186
View File
@@ -0,0 +1,186 @@
use crate::client::durations::format_millis_text;
use crate::client::highlight::Language;
use serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ToolInput {
pub subject: Option<String>,
pub language: Option<Language>,
pub description: Option<String>,
/// How long the call may take, in the largest units it fits. Shown
/// apart because it is a limit on the call rather than part of what
/// the call does.
pub timeout: Option<String>,
/// Everything else, as `name: value` lines. Never dropped.
pub rest: Vec<String>,
}
impl ToolInput {
pub fn title(&self) -> Option<&str> {
self.description
.as_deref()
.or(self.subject.as_deref())
.filter(|t| !t.trim().is_empty())
}
}
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
("Bash", "command", Some(Language::Shell)),
("Read", "file_path", None),
("Write", "file_path", None),
("Edit", "file_path", None),
("Glob", "pattern", None),
("Grep", "pattern", None),
("WebFetch", "url", None),
];
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
/// One function rather than two, because the same coercion decides both
/// what a subject reads as and what a leftover field's value reads as, and
/// two copies would eventually disagree about a number.
fn as_text(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn non_blank(value: Option<&Value>) -> Option<String> {
let text = as_text(value?);
(!text.trim().is_empty()).then_some(text)
}
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
return ToolInput {
rest: match input.trim().is_empty() {
true => Vec::new(),
false => vec![input.to_string()],
},
..ToolInput::default()
};
};
parse_object(tool, &json)
}
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
let (subject_key, language) = SUBJECTS
.iter()
.find(|(name, ..)| *name == tool)
.map(|(_, key, language)| (Some(*key), *language))
.unwrap_or((None, None));
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
let description = DESCRIPTIONS
.iter()
.find_map(|key| non_blank(json.get(*key)));
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
let mut keys: Vec<&String> = json
.keys()
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
.collect();
keys.sort();
let rest = keys
.into_iter()
.map(|key| format!("{key}: {}", as_text(&json[key])))
.collect();
ToolInput {
subject,
language,
description,
timeout,
rest,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_tool_in_the_table_has_its_own_subject() {
let cases = [
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
];
for (tool, input, expected) in cases {
let parsed = parse_tool_input(tool, input);
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
assert_eq!(parsed.title(), Some(expected), "{tool}");
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
}
assert_eq!(
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
Some(Language::Shell),
"a Bash command is shell, and is the one row that names a language"
);
}
#[test]
fn a_tools_own_description_is_what_the_one_line_says() {
let parsed = parse_tool_input(
"Bash",
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
);
assert_eq!(parsed.title(), Some("Run the iris tests"));
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
}
#[test]
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
}
#[test]
fn every_field_not_drawn_elsewhere_is_still_shown() {
let parsed = parse_tool_input(
"Edit",
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
);
assert_eq!(
parsed.rest,
vec![
"new_string: y".to_string(),
"old_string: x".to_string(),
"replace_all: true".to_string(),
],
"sorted, and a non-string value written as JSON"
);
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
assert_eq!(unknown.subject, None);
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
}
#[test]
fn input_that_is_not_an_object_is_still_the_input() {
assert_eq!(
parse_tool_input("Bash", "just a string").rest,
vec!["just a string".to_string()]
);
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
assert_eq!(parse_tool_input("Bash", "").title(), None);
}
#[test]
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
assert_eq!(parsed.subject, None);
assert_eq!(parsed.title(), None);
assert_eq!(
parsed.rest,
vec!["command: ".to_string(), "other: 1".to_string()]
);
}
}
@@ -1,53 +1,17 @@
//! This phone's copy of the transcripts it has already been sent, so
//! reopening a session does not download it again. Ported from
//! `app/.../TranscriptCache.kt`; see `docs/TRANSCRIPT_CACHE.md`
//! for the design and `docs/CLIENT_CORE.md` for how this file corresponds to it.
//!
//! What is stored is the server's own JSON for one event per line, in
//! transcript order. Reading the cache means running the same [`seq_of`]
//! the network path runs, so a cached transcript and a fetched one cannot
//! draw differently, and an event type this build does not know keeps
//! every field it arrived with for the build that will. Rows are
//! deliberately *not* what is stored: a row is a rendering, and a cache of
//! rows would need throwing away on every update that touched the fold.
//!
//! Four rules run through all of it:
//! 1. what is on screen is what the server's transcript says, in order,
//! with nothing missing -- the cache is a copy and is never inferred,
//! folded or edited here;
//! 2. a cached line is never ahead of the live cursor, and the cursor never
//! ahead of the cache;
//! 3. the cache is never load-bearing -- missing, evicted, damaged or
//! unwritable all degrade to a cold open, never to a blank or a wrong
//! screen;
//! 4. a line already on the phone is not fetched again.
//!
//! No JSON parser here: what it needs off a line is the sequence number and
//! whether the line is a streamed delta, both read with a regex-free scan
//! (see [`seq_of`] and [`is_delta`]). A line it cannot read that way is
//! treated as damage.
use std::collections::VecDeque;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// How much of this phone's cache directory all of one server's transcripts
/// may take. A dozen of the largest transcripts seen in the dev VM (21 MB
/// for 24,000 events) and a small fraction of a phone. A number to revisit
/// against real use rather than a measurement of anything.
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
/// What the newest cached line says, which is what the probe checks against
/// the server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CachedTail {
pub seq: u64,
pub line: String,
}
/// This phone's cache root for one server, holding one directory per session.
pub struct TranscriptCache {
root: PathBuf,
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
@@ -68,8 +32,6 @@ impl TranscriptCache {
}
}
/// The cache for one session, whether or not anything has been stored
/// for it yet.
pub fn session(&self, id: &str) -> SessionCache {
SessionCache::new(self.root.join(id), self.warn.clone())
}
@@ -164,36 +126,10 @@ fn dir_size(path: &Path) -> u64 {
.sum()
}
/// One session's cached lines, as a directory of chunks.
///
/// A chunk is a set of lines *and a claim about what they cover*, and the
/// two are not the same thing: a coalesced page joins each run of streamed
/// deltas into one event carrying the seq of the run's oldest delta, so a
/// page whose newest event is seq 1,200 may cover everything up to the
/// 1,650 it was fetched with, and nothing in the lines says so. So coverage
/// is the half-open range in the file's name:
/// `<first>-<end>.rows.jsonl` (a coalesced page; `end` is the `before` it
/// was fetched with) or `<first>-<end>.raw.jsonl` (an uncoalesced page, or a
/// closed live run); `<first>-open.raw.jsonl` is the live run, whose end is
/// its last line's seq + 1.
///
/// Two chunks are adjacent when one's `end` is the other's `first`. Only
/// the contiguous run ending at the newest chunk -- the **suffix** -- is
/// ever served: chunks behind a gap are kept, because the gap is usually
/// closed by paging back through it, but nothing is served across one.
///
/// **The newest chunk is always raw**, which is what makes the stream
/// cursor and the probe well defined.
///
/// Nothing here is load-bearing. Every operation that touches the disk
/// answers as though the cache were empty when it cannot, and a write
/// failure disables writing for the rest of this instance's life so that a
/// full disk costs one log line rather than one per delta.
///
/// A `Mutex` around the writer state stands in for Kotlin's `@Synchronized`:
/// the stream appends live events from its own thread while a reader
/// scrolling back reads pages from another, and this is what keeps the open
/// chunk's name, its end and its writer from being read half-rotated.
pub struct SessionCache {
dir: PathBuf,
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
@@ -202,8 +138,6 @@ pub struct SessionCache {
#[derive(Default)]
struct WriterState {
/// Set by the first write that fails: a second would fail the same way,
/// once per delta.
disabled: bool,
writer: Option<fs::File>,
open_file: Option<PathBuf>,
@@ -238,7 +172,6 @@ impl SessionCache {
})
}
/// The newest `limit` lines of the suffix, oldest first -- the opening window.
pub fn newest(&self, limit: usize) -> Vec<String> {
self.guard(Vec::new(), |this, state| {
let mut taken: VecDeque<String> = VecDeque::new();
@@ -262,11 +195,6 @@ impl SessionCache {
/// below `before` -- and means the server has to be asked. Deliberately
/// not an empty list: an empty page is how the screen is told it has
/// reached the start of the conversation.
///
/// With `rows` the count is rows rather than lines, mirroring the
/// server's `parse_coalesced`. The deltas are not joined here -- the
/// fold does that, and the joined row keeps the seq of its first delta
/// either way.
pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> {
self.guard(None, |this, state| {
let suffix = this.suffix(state)?;
@@ -291,18 +219,12 @@ impl SessionCache {
continue;
}
this.each_line(state, chunk, |line| {
// The page is what is *before* the cursor; the rows at
// or above it are already on screen.
let seq = seq_of(line).expect("chunk lines are checked in each_line");
if seq >= before {
return true;
}
if rows {
let delta = is_delta(line);
// Stop only between rows: a delta continuing the
// run being gathered is part of a row already
// counted, and breaking on it would drop the half
// of that row already taken.
if counted >= limit && !(delta && in_run) {
wanting = false;
} else {
@@ -338,9 +260,6 @@ impl SessionCache {
})
}
/// Stores a fetched page covering `[first, end)`; `false` when it was
/// not stored.
///
/// Refused when it overlaps a chunk already here, because there is no
/// clean cut: a coalesced event cannot be split at a seq inside its own
/// delta run. The caller keeps that from arising by bounding what it
@@ -361,6 +280,10 @@ impl SessionCache {
{
return Ok(false);
}
debug_assert!(
lines.iter().all(|l| !l.contains('\n')),
"a stored page's lines must each be one line"
);
fs::create_dir_all(&this.dir)?;
let kind = if rows { "rows" } else { "raw" };
let mut content = lines.join("\n");
@@ -373,13 +296,6 @@ impl SessionCache {
})
}
/// Appends one live event, which is also how a freshly fetched opening
/// window is stored.
///
/// A seq equal to the open chunk's end extends it. A larger one is a
/// gap -- which is what a `reset` looks like from here -- and closes
/// the open chunk under the end it turned out to have. A smaller one is
/// already covered and is ignored; the SSE contract is `seq > after`.
pub fn append(&self, line: &str, seq: u64) {
self.guard((), |this, state| {
if state.disabled {
@@ -388,9 +304,10 @@ impl SessionCache {
let Some(writer) = this.writer_for(state, seq)? else {
return Ok(());
};
// Written as it arrived. A newline inside it would split one
// event into two unreadable halves, but neither source can
// produce one.
debug_assert!(
!line.contains('\n'),
"a cached transcript line must be one line: {line}"
);
use std::io::Write;
writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?;
@@ -399,7 +316,6 @@ impl SessionCache {
});
}
/// Flushes what [`Self::append`] has buffered.
pub fn flush(&self) {
self.guard((), |_this, state| {
if let Some(writer) = state.writer.as_mut() {
@@ -410,12 +326,10 @@ impl SessionCache {
});
}
/// What [`Self::purge`] would discard, for the reload row in session settings.
pub fn bytes(&self) -> u64 {
self.guard(0, |this, _state| Ok(dir_size(&this.dir)))
}
/// Marks this session as visited, which is what eviction ranks by.
pub fn touch(&self) {
self.guard((), |this, _state| {
if this.dir.is_dir() {
@@ -436,8 +350,6 @@ impl SessionCache {
});
}
// -- chunks ------------------------------------------------------------------------------
/// Every chunk on disk, oldest first. A name this does not recognise is
/// not ours and is ignored. Recomputed per operation rather than kept:
/// another operation may have changed the directory.
@@ -463,9 +375,6 @@ impl SessionCache {
} else {
end_str.parse::<u64>().ok()
};
// A chunk covering nothing is one that was created and never
// written to -- an append whose very first write failed. It
// says nothing, so it is not a chunk.
if let Some(end) = end
&& end > first
{
@@ -482,13 +391,6 @@ impl SessionCache {
Ok(out)
}
/// The open chunk's end: its last line's seq plus one, or the in-memory
/// end while this instance is the one writing it.
///
/// An open chunk whose last line cannot be read is this app having died
/// mid-write. That line is dropped and the file truncated to the last
/// good one, which is the one place damage is repaired rather than
/// discarded.
fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> {
if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
return Some(state.open_end);
@@ -504,12 +406,6 @@ impl SessionCache {
Some(end)
}
/// The contiguous run of adjacent chunks ending at the newest one,
/// oldest first.
///
/// A newest chunk that is not raw cannot happen while this code is the
/// only writer, and means the directory is not to be trusted -- so the
/// session is discarded.
fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> {
let all = self.chunks(state)?;
let Some(newest) = all.last() else {
@@ -528,8 +424,6 @@ impl SessionCache {
Ok(run.into_iter().collect())
}
/// Each line of `chunk`, newest first, until `take` says stop.
///
/// Damage anywhere but at the tail of the open chunk was not written by
/// this code, and there is no honest way to say what a chunk covers
/// with a line of it unreadable -- so it is treated as damage rather
@@ -553,10 +447,6 @@ impl SessionCache {
});
}
// -- writing -----------------------------------------------------------------------------
/// The writer for the chunk `seq` belongs in, opening or rotating one
/// as it has to.
fn writer_for<'s>(
&self,
state: &'s mut WriterState,
@@ -569,14 +459,10 @@ impl SessionCache {
if seq < state.open_end {
return Ok(None);
}
// A gap: what this instance has written covers up to `open_end`,
// and that is the name the chunk gets before a new one starts
// at the arriving seq.
let end = state.open_end;
self.close_open_chunk(state, end);
}
fs::create_dir_all(&self.dir)?;
// An open chunk left by an earlier instance, or by an earlier screen.
let existing = self.chunks(state)?.into_iter().rfind(|c| c.open);
if let Some(existing) = existing {
if seq < existing.end {
@@ -620,8 +506,6 @@ impl SessionCache {
Ok(state.writer.as_mut())
}
/// Renames the open chunk to the range it turned out to cover, so it
/// stops being open.
fn close_open_chunk(&self, state: &mut WriterState, end: u64) {
let file = state.open_file.clone();
close_writer(state);
@@ -635,31 +519,17 @@ impl SessionCache {
}
}
// -- failure -----------------------------------------------------------------------------
/// Runs `body`, answering `if_broken` when the directory cannot give a
/// real answer. None of this is reported on screen: every read here has
/// a network path beside it producing the same result, and the reader
/// has nothing to do about it. Damage discards this session's cache,
/// which makes the next open an ordinary cold one.
fn guard<T>(
&self,
if_broken: T,
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
) -> T {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
// A disk that refused once will refuse again, once per delta, so
// the first refusal is also the last.
if state.disabled {
return if_broken;
}
DAMAGED.with(|cell| *cell.borrow_mut() = None);
let result = body(self, &mut state);
// Damage takes priority over whatever `body` returned, `Ok` or
// `Err`: `suffix` signals it by returning `Err(damaged(..))`
// precisely so this check catches it before the branch below
// mistakes it for a real I/O failure and disables the whole cache
// over one corrupt session.
if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) {
(self.warn)(&format!(
"transcript cache damaged at {}; discarding {}",
@@ -683,11 +553,6 @@ impl SessionCache {
}
thread_local! {
/// How [`SessionCache::each_line`] reports a line it cannot make sense
/// of back up to [`SessionCache::guard`], since the callback it hands
/// `each_line_backwards` cannot itself return a `Result`. Thread-local
/// rather than a field: the guard that reads it always runs on the same
/// call stack that could have set it, one `guard` call at a time.
static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
}
@@ -715,8 +580,6 @@ fn rename_chunk(file: &Path, dir: &Path, first: u64, end: u64) {
let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl")));
}
/// `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is
/// not ours.
fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
let rest = name.strip_suffix(".jsonl")?;
let (rest, kind) = rest.rsplit_once('.')?;
@@ -732,22 +595,14 @@ fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
}
/// One line's sequence number, or `None` when the line is not one of ours.
///
/// A hand-rolled scan rather than a JSON parse, so this module carries no
/// parser and stays testable with no server: the seq is the first field the
/// server writes, so the first match is the top-level one.
pub fn seq_of(line: &str) -> Option<u64> {
find_number_field(line, "seq")
}
/// Whether a line is one streamed piece of a reply, which is what makes a
/// run of them one row.
pub fn is_delta(line: &str) -> bool {
find_string_field(line, "type").as_deref() == Some("assistantText")
}
/// The value of `"key":N` (any amount of whitespace around the colon), or
/// `None`. Mirrors `Regex(""""seq"\s*:\s*(\d+)""")`'s first match.
fn find_number_field(line: &str, key: &str) -> Option<u64> {
let pattern = format!("\"{key}\"");
let at = line.find(&pattern)?;
@@ -765,8 +620,6 @@ fn find_number_field(line: &str, key: &str) -> Option<u64> {
}
}
/// The value of `"key":"..."`, or `None`. Mirrors
/// `Regex(""""type"\s*:\s*"([^"]*)"""")`'s first match.
fn find_string_field(line: &str, key: &str) -> Option<String> {
let pattern = format!("\"{key}\"");
let at = line.find(&pattern)?;
@@ -777,18 +630,11 @@ fn find_string_field(line: &str, key: &str) -> Option<String> {
Some(after_quote[..end].to_string())
}
/// How much of a file is read at a time when walking it backwards. One
/// block covers a page of a transcript comfortably, and the walk stops as
/// soon as the caller has what it asked for.
const READ_BLOCK: usize = 64 * 1024;
/// Calls `on_line` with each non-blank line of `file`, **newest first**,
/// along with the byte offset it starts at, until `on_line` answers false.
///
/// Every question the cache is asked is about the newest end of a chunk,
/// and a live run reaches the size of the conversation, so reading forwards
/// means reading a transcript to answer with the last eighty lines of it.
///
/// Splitting on bytes is safe because the separator is `\n`, which cannot
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
/// missing file yields nothing.
@@ -811,7 +657,6 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
}
let mut buffer = block;
buffer.extend_from_slice(&pending);
// `buffer` is now `block` followed by `pending`; walk it backwards.
let mut line_end = buffer.len();
let mut at = buffer.len() as isize - 1;
while at >= 0 {
@@ -828,20 +673,12 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
pending = buffer[..line_end].to_vec();
unread = start;
}
// The first line of a file has no newline before it to be found.
let first = String::from_utf8_lossy(&pending);
if !first.trim().is_empty() {
on_line(0, &first);
}
}
/// Drops a final line that is not one of ours, by truncating the file to
/// where it starts.
///
/// This app having died mid-write is the one kind of damage that is
/// repaired rather than discarded: the tail of an append-only file is the
/// only place a partial line can be. A second bad line is not this, and is
/// left for the read path to notice.
fn repair_tail(file: &Path) -> io::Result<()> {
let mut truncate_to: Option<u64> = None;
each_line_backwards(file, |offset, line| {
@@ -857,9 +694,6 @@ fn repair_tail(file: &Path) -> io::Result<()> {
Ok(())
}
/// Runs `body`, translating an I/O or permission failure into `if_broken`
/// and a warning -- the disk half of [`SessionCache::guard`], shared with
/// [`TranscriptCache`]'s own maintenance.
fn guard_io<T>(
if_broken: T,
warn: &(impl Fn(&str) + ?Sized),
@@ -874,10 +708,6 @@ fn guard_io<T>(
}
}
/// Sets a path's modified time, without pulling in a crate for it: a single
/// `utimensat`-backed call would be one more platform-specific dependency
/// for one call site, so this touches the file instead, which every
/// filesystem this runs on updates the mtime for.
fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> {
use std::io::Write;
// Rewriting a marker file's contents (rather than the directory itself,
@@ -908,7 +738,6 @@ mod tests {
})
}
/// Like `cache`, but also hands back the messages it warned with.
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
let said2 = said.clone();
@@ -984,8 +813,6 @@ mod tests {
})
);
assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]);
// More than there is is what there is, which is a short opening
// window and not a failure.
assert_eq!(session.newest(80).len(), 3);
}
@@ -997,8 +824,6 @@ mod tests {
for seq in 1..=3u64 {
session.append(&tool_line(seq), seq);
}
// What a `reset` looks like from here: the next event is not the
// one after the last.
session.append(&tool_line(90), 90);
session.flush();
@@ -1040,14 +865,11 @@ mod tests {
}
session.flush();
// Adjacent: its end is the open chunk's first.
let page: Vec<String> = (60..100u64).map(tool_line).collect();
assert!(session.store_page(&page, 60, 100, true));
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60));
// Behind a gap: kept on disk, because paging usually closes the
// gap, but never served across it.
let page2: Vec<String> = (1..10u64).map(tool_line).collect();
assert!(session.store_page(&page2, 1, 10, true));
assert_eq!(session.page(10, 5, false), None);
@@ -1083,8 +905,6 @@ mod tests {
}
session.flush();
// At or below where the run starts, so what the reader is
// scrolling into is the server's.
assert_eq!(session.page(100, 40, true), None);
assert_eq!(session.page(40, 40, true), None);
assert_eq!(cache.session("never-visited").page(100, 40, true), None);
@@ -1109,8 +929,6 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let cache = cache(temp.path());
let session = cache.session("s");
// Two replies of three deltas each, split by a tool call: the same
// fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`.
let lines = vec![
delta(1),
delta(2),
@@ -1125,14 +943,8 @@ mod tests {
session.append(&tool_line(9), 9);
session.flush();
// Three rows: the tool call at 8, the run 5..7, and the tool call
// at 4. The cut lands between rows, so the older run is not
// started.
assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8]));
// One row is one whole run, however many deltas it is made of.
assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8]));
// A page of lines counts lines, which is what the anchor restore
// asks for.
assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8]));
}
@@ -1151,10 +963,7 @@ mod tests {
session.append(&tool_line(10), 10);
session.flush();
// A run straddling the boundary is one row, as it will be once folded.
assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10]));
// Asking for more rows than the suffix holds is a short page, not a
// failure and not a claim that the conversation starts here.
assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect()));
}
@@ -1178,13 +987,9 @@ mod tests {
session.append(&tool_line(90), 90);
session.flush();
// The run behind the gap, which is what makes the fetched page
// adjacent to it.
assert_eq!(session.covered_up_to(90), Some(40));
assert_eq!(session.covered_up_to(41), Some(40));
assert_eq!(session.covered_up_to(10), Some(10));
// Nothing at or below the oldest chunk's start, so the page is
// bounded only by its limit.
assert_eq!(session.covered_up_to(9), None);
}
@@ -1200,9 +1005,6 @@ mod tests {
&(1..10u64).map(tool_line).collect::<Vec<_>>(),
);
// Only reachable by dying between closing one live run and opening
// the next, and there is no cursor to be read off a coalesced line
// -- so the open is a cold one.
assert_eq!(session.tail(), None);
assert!(!dir_of(temp.path(), "s").exists());
}
@@ -1231,7 +1033,6 @@ mod tests {
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
format!("{}\n{}\n", tool_line(1), tool_line(2))
);
// And the run continues from where the good tail left off.
session.append(&tool_line(3), 3);
session.flush();
assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]);
@@ -1249,7 +1050,6 @@ mod tests {
&[tool_line(1), "not ours".to_string(), tool_line(3)],
);
// Not seen by the tail, which reads the newest line and stops.
assert_eq!(
session.tail(),
Some(CachedTail {
@@ -1257,8 +1057,6 @@ mod tests {
line: tool_line(3)
})
);
// Reached by a read that walks past it: what is served is nothing,
// and the session opens cold from here on.
assert_eq!(session.newest(80), Vec::<String>::new());
assert!(!dir_of(temp.path(), "s").exists());
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
@@ -1286,9 +1084,6 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let cache = cache(temp.path());
let session = cache.session("s");
// Well past the 64 kB block the backwards reader takes at a time,
// so a page has to be stitched across several of them -- including
// a line that straddles a boundary.
let padding = "x".repeat(300);
let lines: Vec<String> = (1..=500u64)
.map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#))
@@ -1298,8 +1093,6 @@ mod tests {
assert_eq!(session.tail().unwrap().seq, 500);
assert_eq!(session.newest(80), lines[420..].to_vec());
assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec()));
// And a non-ASCII line, whose bytes a naive split could cut through
// a character.
let accented =
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
session.append(&accented, 501);
@@ -1321,15 +1114,10 @@ mod tests {
let when =
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64);
filetime_set_modified(&dir_of(temp.path(), id), when).unwrap();
// The mtime touch above always sets "now", not `when` (see its
// own doc) -- space the three writes out in real time instead,
// since only relative order matters to eviction.
std::thread::sleep(std::time::Duration::from_millis(10));
}
let each = dir_size(&dir_of(temp.path(), "old"));
// Room for two of the three, so the oldest goes -- and the session
// being read never does, however long ago it was last touched.
cache.evict_to_budget("open", each * 2);
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
.unwrap()
@@ -1382,8 +1170,6 @@ mod tests {
session.purge();
assert_eq!(session.bytes(), 0);
assert_eq!(session.tail(), None);
// And the session is usable again straight afterwards, which is
// what a reload does next.
session.append(&tool_line(9), 9);
session.flush();
assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
@@ -1,32 +1,5 @@
//! What the transcript renders: the event stream folded into displayable
//! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s
//! non-Compose half (`TranscriptRow`, `groupToolRuns`).
//!
//! Events are the only data source, and there is deliberately no second
//! shape for history to drift from: a page fetched backwards, a live
//! frame, and a line read out of the transcript cache are all the same
//! events through the same fold.
//!
//! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into
//! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer
//! exists to bound how much a lazy list composes per frame, which is a
//! fact about the UI framework drawing it, not about the transcript. See
//! `CLIENT_CORE.md`.
//!
//! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate
//! deserializes straight into [`event_model::Event`], which has no
//! `Unknown` catch-all -- an event type this build does not recognise
//! fails to parse rather than degrading to a placeholder row. Closing that
//! gap means giving `event_model::Event` its own forward-compatible
//! variant, which is a shared-model decision for both sides of the wire
//! and is deliberately left for whoever picks this up next (see
//! `CLIENT_CORE.md`).
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
/// A question this build has already asked the reader about, with what was
/// answered so far -- distinct from [`QuestionOption`], which is what could
/// be chosen.
#[derive(Debug, Clone, PartialEq)]
pub struct QuestionCard {
pub seq: u64,
@@ -38,24 +11,14 @@ pub struct QuestionCard {
pub answers: Vec<String>,
}
/// A tool call cannot be recognised as `AskUserQuestion` from a bare
/// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption
/// logic name it explicitly.
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
/// This item's identity in the list: a `Seq` for everything with no
/// identity of its own, `RunId` for a tool call (which keeps one across
/// however many calls join or leave its run), matching `TranscriptItem.key`
/// in the Kotlin original.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ItemKey {
Seq(u64),
RunId(String),
}
/// One row of the transcript, folded from [`Event`]s. See each variant's
/// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this
/// doc only says what changed in translation.
#[derive(Debug, Clone, PartialEq)]
pub enum TranscriptItem {
UserMsg {
@@ -66,8 +29,6 @@ pub enum TranscriptItem {
AssistantMsg {
seq: u64,
text: String,
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
/// Kotlin doc for why the split it licenses matters.
settled: bool,
},
ToolRun {
@@ -78,6 +39,11 @@ pub enum TranscriptItem {
input: String,
output: String,
done: bool,
/// Whether the result that arrived said the call failed
/// ([`Event::ToolEnd`]'s `is_error`). Meaningless while `done` is
/// false, and [`ToolState::of`] is the only thing that reads the
/// pair, so the two cannot be combined wrongly at a call site.
failed: bool,
asks: Vec<QuestionCard>,
images: Vec<String>,
},
@@ -90,9 +56,6 @@ pub enum TranscriptItem {
seq: u64,
r#ref: String,
},
/// A message from another agent. `arrived` is this row's own identity
/// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which
/// [`place_peer_note`] may set to the turn's opening seq instead.
PeerNote {
seq: u64,
from: String,
@@ -103,8 +66,6 @@ pub enum TranscriptItem {
seq: u64,
text: String,
},
/// Placeholder for an event kind this build could not fold -- see the
/// module doc's "known gap".
Note {
seq: u64,
text: String,
@@ -112,6 +73,13 @@ pub enum TranscriptItem {
ClearedNote {
seq: u64,
},
/// The account's usage limit stopped the turn; `resets_at` is epoch
/// seconds when the dialect said when it lifts (`LimitNote` in
/// `TranscriptItems.kt`).
LimitNote {
seq: u64,
resets_at: Option<f64>,
},
CompactedNote {
seq: u64,
pre_tokens: Option<u64>,
@@ -131,6 +99,7 @@ impl TranscriptItem {
| Self::CommandRow { seq, .. }
| Self::Note { seq, .. }
| Self::ClearedNote { seq }
| Self::LimitNote { seq, .. }
| Self::CompactedNote { seq, .. } => *seq,
Self::QuestionCard(card) => card.seq,
}
@@ -188,14 +157,10 @@ fn update_tool(
.collect()
}
/// Whether a status means the session is still doing something, mirroring
/// `sessionWorking` in `Events.kt`.
pub fn session_working(status: SessionStatus) -> bool {
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
}
/// A status saying the session stopped working is the moment its newest
/// reply is finished.
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
if session_working(status) {
return items.to_vec();
@@ -210,9 +175,6 @@ fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<Transcri
items
}
/// A peer message goes above the turn it started, not where it happened to
/// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning;
/// `turn_start` is `Event::PeerMessage`'s own field of that name.
fn place_peer_note(
items: &[TranscriptItem],
seq: u64,
@@ -251,8 +213,6 @@ fn place_peer_note(
out
}
/// The calls the note now sits in front of, renamed if they were sharing a
/// run with the calls behind it. See the Kotlin `splitRun`'s doc.
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
let Some(TranscriptItem::ToolRun {
run_id: first_run_id,
@@ -286,10 +246,160 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
out
}
/// Folds one transcript event onto `items`, the way `foldEvent` does in
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
/// for the one difference from the Kotlin original (no `Unknown` fallback
/// at the parse layer).
/// A boundary lands wherever it lands, and roughly half the time that is
/// between a call and its result. The newer page then holds a `ToolEnd`
/// whose start it never saw, which `fold_event` draws as a row of its own
/// -- correctly, because a call that renders as nothing is indistinguishable
/// from one that never happened. When the older page arrives it brings the
/// real `ToolStart`, and concatenating the two lists left *both*: the same
/// call twice.
///
/// Merged by the call's own id rather than by position, because position is
/// exactly what a page boundary destroys. The older row wins on what a
/// start knows and the newer on what an end knows, which is the only way
/// round that loses nothing.
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
let (older, newer) = heal_split_message(earlier, later);
let started_earlier: std::collections::HashSet<&str> = older
.iter()
.filter_map(TranscriptItem::as_tool_run)
.collect();
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
.iter()
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
.filter(|(id, _)| started_earlier.contains(id.as_str()))
.collect();
let healed: Vec<TranscriptItem> = older
.into_iter()
.map(|row| match row {
TranscriptItem::ToolRun {
seq,
id,
run_id,
tool,
input,
asks: row_asks,
images: row_images,
..
} if ended_later.contains_key(id.as_str()) => {
let &TranscriptItem::ToolRun {
ref output,
done,
failed,
asks: ref half_asks,
images: ref half_images,
..
} = &ended_later[id.as_str()]
else {
unreachable!("filtered to ToolRun above");
};
TranscriptItem::ToolRun {
seq,
id,
run_id,
tool,
input,
output: output.clone(),
done,
failed,
asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
images: row_images.into_iter().chain(half_images.clone()).collect(),
}
}
other => other,
})
.collect();
let kept: Vec<TranscriptItem> = newer
.into_iter()
.filter(|item| match item.as_tool_run() {
Some(id) => !ended_later.contains_key(id),
None => true,
})
.collect();
let mut out = adopt_run(&healed, &kept);
out.extend(kept);
// What this function exists to prevent, checked rather than assumed: the same
// call drawn twice, once from the page that saw its start and once from the page
// that saw its end. Not a seq-ordering check -- a peer note is stamped with the
// seq its turn began at, which can be older than the page it arrived in, so the
// two pages' seqs legitimately interleave at the boundary.
debug_assert!(
{
let mut ids: Vec<&str> = out.iter().filter_map(TranscriptItem::as_tool_run).collect();
let before = ids.len();
ids.sort_unstable();
ids.dedup();
ids.len() == before
},
"join_pages left the same tool call in both halves"
);
out
}
/// `fold_event` never leaves two assistant messages next to each other
/// inside one page, so two meeting at a join are always the two halves of
/// one reply, and leaving them apart drew a single answer as two with a
/// paragraph break through the middle of a sentence.
fn heal_split_message(
earlier: &[TranscriptItem],
later: &[TranscriptItem],
) -> (Vec<TranscriptItem>, Vec<TranscriptItem>) {
let (
Some(TranscriptItem::AssistantMsg {
text: head_text, ..
}),
Some(TranscriptItem::AssistantMsg {
seq: tail_seq,
text: tail_text,
settled: tail_settled,
}),
) = (earlier.last(), later.first())
else {
return (earlier.to_vec(), later.to_vec());
};
let merged = TranscriptItem::AssistantMsg {
seq: *tail_seq,
text: format!("{head_text}{tail_text}"),
settled: *tail_settled,
};
let mut newer = vec![merged];
newer.extend(later[1..].iter().cloned());
(earlier[..earlier.len() - 1].to_vec(), newer)
}
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
return earlier.to_vec();
};
if tool == ASK_USER_QUESTION {
return earlier.to_vec();
}
let joining = run_id.clone();
let tail_len = earlier
.iter()
.rev()
.take_while(|item| matches!(item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION))
.count();
if tail_len == 0 {
return earlier.to_vec();
}
let split = earlier.len() - tail_len;
let mut out = earlier[..split].to_vec();
out.extend(earlier[split..].iter().cloned().map(|mut item| {
// `take_while` above already restricted this slice to non-question tool calls;
// this just guards the invariant rather than trusting it silently.
debug_assert!(
matches!(&item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION),
"adopt_run must never rename a question's own run"
);
if let TranscriptItem::ToolRun { run_id, .. } = &mut item {
*run_id = joining.clone();
}
item
}));
out
}
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
let seq = entry.seq;
match &entry.event {
@@ -304,9 +414,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
});
items
}
// `MessageTaken` is folded into `UserMessage` by the manager before
// it reaches a phone (see `PLAN.md`); if one arrives here anyway
// (a raw transcript line, say), it reads the same way.
Event::MessageTaken {
text, attachments, ..
} => {
@@ -319,12 +426,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
items
}
Event::AssistantText { delta } => {
// Deltas accumulate into the message they're streaming, which
// keeps the seq of the *first* of them: a row whose identity
// changed with every delta would be a new row every frame.
// "A message growing again is not finished" -- whatever a
// status said in between -- is why this always clears
// `settled` rather than preserving it.
if let Some(TranscriptItem::AssistantMsg {
seq: first_seq,
text,
@@ -361,6 +462,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
input: input.to_string(),
output: String::new(),
done: false,
failed: false,
asks: Vec::new(),
images: Vec::new(),
});
@@ -371,15 +473,23 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
*out = output.clone();
}
}),
Event::ToolEnd { id, output } => {
Event::ToolEnd {
id,
output,
is_error,
} => {
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
update_tool(items, id, |item| {
if let TranscriptItem::ToolRun {
output: out, done, ..
output: out,
done,
failed,
..
} = item
{
*out = output.clone();
*done = true;
*failed = *is_error;
}
})
} else {
@@ -393,6 +503,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
input: String::new(),
output: output.clone(),
done: true,
failed: *is_error,
asks: Vec::new(),
images: Vec::new(),
});
@@ -449,6 +560,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
input,
output,
done,
failed,
images,
} if asks.iter().any(|a| &a.id == id) => {
for ask in asks.iter_mut() {
@@ -464,6 +576,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
input,
output,
done,
failed,
asks,
images,
}
@@ -484,7 +597,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
});
items
}
// Screen-level state, not transcript rows.
Event::CommandQueued { .. }
| Event::MessageQueued { .. }
| Event::MessageDropped { .. }
@@ -525,6 +637,14 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
items.push(TranscriptItem::ClearedNote { seq });
items
}
Event::LimitReached { resets_at } => {
let mut items = items.to_vec();
items.push(TranscriptItem::LimitNote {
seq,
resets_at: *resets_at,
});
items
}
Event::Compacted {
pre_tokens,
post_tokens,
@@ -541,14 +661,55 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
}
}
/// One row as the transcript draws it: a run of consecutive tool calls, or
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
/// `groupToolRuns` -- the Compose card rendering in that file is not part
/// of this crate.
/// The pair this enum exists for is [`ToolState::Succeeded`] against
/// [`ToolState::NoResult`]. A call that finished having printed nothing
/// and a call whose result never arrived both leave an empty `output`,
/// and drawing them the same way states a verdict nobody reached: "it
/// worked and said nothing" reads as a fact, where the truth is that the
/// turn ended before anything came back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolState {
Running,
/// Stopped on the reader: a permission or question this call carries
/// has not been answered, so nothing is happening until somebody
/// answers it. Distinct from [`Self::Running`] because whose move it
/// is differs, which is the Compose card's "your turn".
Deciding,
Succeeded,
Failed,
NoResult,
}
impl ToolState {
pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> {
let TranscriptItem::ToolRun {
done, failed, asks, ..
} = item
else {
return None;
};
debug_assert!(
!failed || *done,
"a call cannot have failed before its result arrived"
);
Some(if asks.iter().any(|ask| ask.answers.is_empty()) {
Self::Deciding
} else if !*done {
match session_working {
true => Self::Running,
false => Self::NoResult,
}
} else if *failed {
Self::Failed
} else {
Self::Succeeded
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum TranscriptRow {
Single(TranscriptItem),
/// Two or more calls with nothing between them.
Tools(Vec<TranscriptItem>),
}
@@ -568,9 +729,6 @@ impl TranscriptRow {
}
}
/// Runs of adjacent tool calls become one row; everything else passes
/// through. See the Kotlin `groupRuns`'s doc for why grouping is by the
/// run each call names rather than by adjacency worked out here.
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
let mut rows = Vec::new();
let mut run: Vec<TranscriptItem> = Vec::new();
@@ -606,15 +764,6 @@ pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
rows
}
/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s
/// `Vec<Value>`) into the flat item list this module works over. A line
/// this build can't parse fails the whole page rather than being skipped --
/// CODE_RULES's "an enumeration must be able to say 'it broke'" -- since
/// silently dropping one event could hide, say, a user message that then
/// looks like it was never sent. Moved here from `desktop-app`'s `app.rs`
/// (RUST.md's E4) when the Android transcript client (I5) needed the same
/// fold: "write the logic once" applies to any caller embedding
/// `transcript-ui` against a live server, not just the first one.
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
let mut items = Vec::new();
for value in values {
@@ -626,13 +775,6 @@ pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, St
Ok(items)
}
/// The wire `seq` a raw transcript line carries -- the live-stream resume
/// cursor after loading a page must be this, not a folded item's `seq()`.
/// A folded `AssistantMsg` keeps the seq of the *first* delta it
/// accumulated (`fold_event`'s own doc), so resuming from that seq would
/// re-deliver every delta already folded into it, duplicating the tail of
/// a reply that was mid-stream when the page was fetched -- found via a
/// real screenshot in E4 (RUST.md), where the assistant's line doubled.
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
value.get("seq")?.as_u64()
}
@@ -780,6 +922,7 @@ mod tests {
Event::ToolEnd {
id: "x".to_string(),
output: "done".to_string(),
is_error: false,
},
)]);
assert_eq!(
@@ -792,6 +935,7 @@ mod tests {
input: String::new(),
output: "done".to_string(),
done: true,
failed: false,
asks: Vec::new(),
images: Vec::new(),
}]
@@ -863,12 +1007,6 @@ mod tests {
obj
}
/// The regression for a bug a real `run-headless.sh` screenshot found
/// in `desktop-app` (E4, RUST.md): resuming the live stream from the
/// last *item's* seq re-delivers the deltas already folded into a
/// still-open assistant message, doubling its tail. `raw_seq` of the
/// last wire line must be the true high-water mark instead, which for a
/// run of deltas is higher than every item's own `seq()`.
#[test]
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
let values = vec![
@@ -939,4 +1077,261 @@ mod tests {
let err = fold_page(&values).unwrap_err();
assert!(err.contains("couldn't parse"));
}
fn tool_start(seq: u64, id: &str, tool: &str) -> SeqEvent {
event(
seq,
Event::ToolStart {
id: id.to_string(),
tool: tool.to_string(),
input: serde_json::json!({}),
},
)
}
fn tool_end(seq: u64, id: &str, output: &str) -> SeqEvent {
event(
seq,
Event::ToolEnd {
id: id.to_string(),
output: output.to_string(),
is_error: false,
},
)
}
#[test]
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
let newer = fold_all(&[tool_start(3, "b", "Bash"), tool_end(4, "b", "new output")]);
let joined = join_pages(&older, &newer);
let run_ids: Vec<_> = joined
.iter()
.map(|item| match item {
TranscriptItem::ToolRun { run_id, .. } => run_id.as_str(),
other => panic!("expected only ToolRun items, got {other:?}"),
})
.collect();
assert_eq!(
run_ids,
vec!["b", "b"],
"the older call must adopt the newer, already-on-screen run's name"
);
}
#[test]
fn a_call_split_across_the_boundary_merges_into_one_row() {
let older = fold_all(&[tool_start(1, "x", "Bash")]);
let newer = fold_all(&[tool_end(2, "x", "the result")]);
let joined = join_pages(&older, &newer);
assert_eq!(
joined,
vec![TranscriptItem::ToolRun {
seq: 1,
id: "x".to_string(),
run_id: "x".to_string(),
tool: "Bash".to_string(),
input: "{}".to_string(),
output: "the result".to_string(),
done: true,
failed: false,
asks: Vec::new(),
images: Vec::new(),
}],
"the older half's tool/input and the newer half's output/done must both survive"
);
}
#[test]
fn a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity() {
let older = vec![TranscriptItem::AssistantMsg {
seq: 1,
text: "Hel".to_string(),
settled: false,
}];
let newer = vec![
TranscriptItem::AssistantMsg {
seq: 2,
text: "lo".to_string(),
settled: true,
},
TranscriptItem::UserMsg {
seq: 3,
text: "next".to_string(),
attachments: Vec::new(),
},
];
let joined = join_pages(&older, &newer);
assert_eq!(
joined,
vec![
TranscriptItem::AssistantMsg {
seq: 2,
text: "Hello".to_string(),
settled: true,
},
TranscriptItem::UserMsg {
seq: 3,
text: "next".to_string(),
attachments: Vec::new(),
},
]
);
}
#[test]
fn adopt_run_never_renames_into_a_question_row() {
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
let newer = vec![TranscriptItem::ToolRun {
seq: 3,
id: "q".to_string(),
run_id: "q".to_string(),
tool: ASK_USER_QUESTION.to_string(),
input: "{}".to_string(),
output: String::new(),
done: false,
failed: false,
asks: Vec::new(),
images: Vec::new(),
}];
let joined = join_pages(&older, &newer);
match &joined[0] {
TranscriptItem::ToolRun { run_id, .. } => assert_eq!(run_id, "a"),
other => panic!("expected a ToolRun, got {other:?}"),
}
}
}
#[cfg(test)]
mod tool_state_tests {
use super::*;
fn event(seq: u64, e: Event) -> SeqEvent {
SeqEvent {
seq,
ts: 0.0,
event: e,
}
}
fn fold_all(events: &[SeqEvent]) -> Vec<TranscriptItem> {
events
.iter()
.fold(Vec::new(), |items, e| fold_event(&items, e))
}
fn start(id: &str) -> SeqEvent {
event(
1,
Event::ToolStart {
id: id.to_string(),
tool: "Bash".to_string(),
input: serde_json::json!({"command": "ls"}),
},
)
}
fn end(id: &str, output: &str, is_error: bool) -> SeqEvent {
event(
2,
Event::ToolEnd {
id: id.to_string(),
output: output.to_string(),
is_error,
},
)
}
fn state_of(events: &[SeqEvent], session_working: bool) -> ToolState {
let items = fold_all(events);
ToolState::of(&items[0], session_working).expect("the fixture's first item is a tool call")
}
#[test]
fn a_result_that_arrived_is_read_from_is_error() {
assert_eq!(
state_of(&[start("a"), end("a", "ok", false)], false),
ToolState::Succeeded
);
assert_eq!(
state_of(&[start("a"), end("a", "No such file", true)], false),
ToolState::Failed
);
}
#[test]
fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() {
assert_eq!(
state_of(&[start("a"), end("a", "", false)], false),
ToolState::Succeeded,
"a result arrived; it was empty"
);
assert_eq!(
state_of(&[start("a")], false),
ToolState::NoResult,
"no result, and the session is not working any more"
);
}
#[test]
fn no_result_while_the_session_works_is_still_running() {
assert_eq!(state_of(&[start("a")], true), ToolState::Running);
}
#[test]
fn an_unanswered_ask_is_the_readers_move_whatever_else_is_true() {
let asking = event(
3,
Event::Question {
id: "q1".to_string(),
prompt: "Allow?".to_string(),
header: None,
options: vec![QuestionOption {
label: "Allow".to_string(),
description: None,
preview: None,
}],
multi_select: false,
about: Some("a".to_string()),
},
);
let answered = event(
4,
Event::Answered {
id: "q1".to_string(),
answers: vec!["Allow".to_string()],
},
);
assert_eq!(
state_of(&[start("a"), asking.clone()], true),
ToolState::Deciding
);
assert_eq!(
state_of(&[start("a"), asking.clone()], false),
ToolState::Deciding
);
assert_eq!(
state_of(
&[start("a"), asking, answered, end("a", "ok", false)],
false
),
ToolState::Succeeded,
"once it is answered the call is an ordinary one again"
);
}
#[test]
fn nothing_but_a_tool_call_has_a_tool_state() {
assert_eq!(
ToolState::of(
&TranscriptItem::UserMsg {
seq: 1,
text: "hi".to_string(),
attachments: Vec::new(),
},
true
),
None
);
}
}
+479
View File
@@ -0,0 +1,479 @@
use event_model::SeqEvent;
use crate::client::api::{ApiClient, ApiError, Transport};
use crate::client::event_stream::{self, StreamItem};
use crate::client::transcript_cache::SessionCache;
/// The server's own default page size, named here because the cached
/// opening has to be the same size as the fetched one -- a reader must not
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
/// in the Kotlin original).
pub const OPENING_WINDOW: u32 = 80;
/// A transcript-line parse failure, told apart from [`ApiError`] so a
/// caller can tell "the server is unreachable" from "the server (or this
/// phone's own disk) sent something this build cannot read" -- the two
/// mean different things to a reader (retry, versus a build that is
/// behind).
#[derive(Debug, Clone)]
pub struct ParseError(pub String);
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone)]
pub enum PageError {
Api(ApiError),
Parse(ParseError),
}
impl From<ApiError> for PageError {
fn from(e: ApiError) -> Self {
Self::Api(e)
}
}
impl From<ParseError> for PageError {
fn from(e: ParseError) -> Self {
Self::Parse(e)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum OlderPage {
Events(Vec<SeqEvent>),
NothingLoaded,
}
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
}
pub struct TranscriptSource<T: Transport> {
api: ApiClient<T>,
session_id: String,
pub cache: SessionCache,
}
impl<T: Transport> TranscriptSource<T> {
pub fn new(api: ApiClient<T>, session_id: impl Into<String>, cache: SessionCache) -> Self {
Self {
api,
session_id: session_id.into(),
cache,
}
}
/// The cached opening window, or `None` when there is nothing usable
/// to draw.
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
self.cache.tail()?;
let lines = self.cache.newest(limit);
if lines.is_empty() {
return None;
}
match lines.iter().map(|l| parse_line(l)).collect() {
Ok(events) => Some(events),
Err(ParseError(_)) => {
self.cache.purge();
None
}
}
}
/// A caller must not resume a live stream from a cached seq unless it
/// is the same conversation: a transcript is append-only in ordinary
/// use, but the file backing it can be replaced or truncated (a
/// sandbox re-seeded with the same ids, a backup restored, a session
/// re-imported), and the server's catch-up on such a file would hand
/// this phone a continuation of a *different* conversation, spliced
/// onto the cached one with no seam. Caught with one request of a few
/// hundred bytes.
///
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
/// server not being askable, which is neither: the cached rows stay
/// on screen and the caller tries again on its own reconnect schedule.
pub fn probe(&self) -> Result<bool, ApiError> {
let Some(tail) = self.cache.tail() else {
return Ok(false);
};
let page = self.api.fetch_transcript_lines(
&self.session_id,
Some(tail.seq + 1),
1,
false,
None,
)?;
let matches = page.len() == 1
&& parse_line(&tail.line)
.map(|cached| cached == page[0].1)
.unwrap_or(false);
if !matches {
self.cache.purge();
}
Ok(matches)
}
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
let page =
self.api
.fetch_transcript_lines(&self.session_id, None, OPENING_WINDOW, false, None)?;
for (line, event) in &page {
self.cache.append(line, event.seq);
}
self.cache.flush();
Ok(page.into_iter().map(|(_, event)| event).collect())
}
/// The page before `before`: from the cache when it holds it,
/// otherwise from the server bounded by what the cache already has.
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
if before == 0 {
return Ok(OlderPage::NothingLoaded);
}
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
let events: Vec<SeqEvent> = lines
.iter()
.map(|l| parse_line(l).map_err(PageError::from))
.collect::<Result<_, _>>()?;
return Ok(OlderPage::Events(events));
}
let after = self.cache.covered_up_to(before).map(|v| v - 1);
let page = self.api.fetch_transcript_lines(
&self.session_id,
Some(before),
limit,
coalesce,
after,
)?;
if let Some((_, first_event)) = page.first() {
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
self.cache
.store_page(&lines, first_event.seq, before, coalesce);
}
Ok(OlderPage::Events(
page.into_iter().map(|(_, event)| event).collect(),
))
}
/// Before, so that an event held back for a reader who is scrolled
/// away is already on disk -- what the cache holds is what the server
/// sent, not what a screen has got round to drawing. Flushed on each
/// status change, which is a turn's boundary and the granularity a
/// crash may as well lose, and once more when the stream ends.
pub fn follow(
&self,
after: u64,
mut on_item: impl FnMut(StreamItem) -> bool,
) -> Result<(), ApiError> {
let cache = &self.cache;
let result = event_stream::follow_session_events(
self.api.transport(),
&self.session_id,
after,
|item| {
if let StreamItem::Event { raw, event } = &item {
cache.append(raw, event.seq);
if matches!(event.event, event_model::Event::Status { .. }) {
cache.flush();
}
}
on_item(item)
},
);
cache.flush();
result
}
/// Leaves the cache with everything it was given -- called once a
/// caller is done with this source, mirroring the Kotlin `close`'s
/// final flush (that method's stream cancellation itself is the
/// runtime concern the module doc says is not ported here).
pub fn close(&self) {
self.cache.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::api::{Body, RawResponse};
use std::collections::VecDeque;
use std::io::Read;
use std::sync::Mutex;
#[derive(Default)]
struct ScriptedTransport {
responses: Mutex<VecDeque<(u16, String)>>,
calls: Mutex<Vec<String>>,
}
impl ScriptedTransport {
fn respond(&self, status: u16, body: impl Into<String>) {
self.responses
.lock()
.unwrap()
.push_back((status, body.into()));
}
fn call_count(&self) -> usize {
self.calls.lock().unwrap().len()
}
}
impl Transport for ScriptedTransport {
fn request(
&self,
_method: &str,
path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
self.calls.lock().unwrap().push(path.to_string());
let (status, body) = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| panic!("ScriptedTransport got an unscripted request: {path}"));
Ok(RawResponse {
status,
body: body.into_bytes(),
})
}
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
self.calls.lock().unwrap().push(path.to_string());
let (_, body) = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| {
panic!("ScriptedTransport got an unscripted stream request: {path}")
});
Ok(Box::new(std::io::Cursor::new(body.into_bytes())))
}
}
fn source(
transport: ScriptedTransport,
cache_root: &std::path::Path,
) -> TranscriptSource<ScriptedTransport> {
let api = ApiClient::new(transport);
let cache = crate::client::transcript_cache::TranscriptCache::new(cache_root).session("s1");
TranscriptSource::new(api, "s1", cache)
}
fn status_line(seq: u64) -> String {
format!(r#"{{"seq":{seq},"ts":1.0,"type":"status","state":"idle"}}"#)
}
#[test]
fn a_cold_cache_has_no_opening_and_fetches_from_the_server() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
assert_eq!(source.cached_opening(80), None);
let opening = source.fetch_opening().unwrap();
assert_eq!(opening.len(), 1);
assert_eq!(opening[0].seq, 1);
assert!(source.cache.tail().is_some());
}
#[test]
fn probe_matching_the_cached_tail_leaves_the_cache_alone() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(1)));
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().unwrap());
assert!(source2.cache.tail().is_some());
}
#[test]
fn probe_mismatching_the_cached_tail_purges_the_cache() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
transport2.respond(200, format!("[{different}]"));
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(!source2.probe().unwrap());
assert!(source2.cache.tail().is_none());
}
#[test]
fn probe_finding_no_server_leaves_the_cache_untouched() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(500, "server on fire");
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().is_err());
assert!(
source2.cache.tail().is_some(),
"an unreachable server must not be treated as a mismatch"
);
}
#[test]
fn paging_before_the_first_event_makes_no_request_at_all() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
let source = source(transport, dir.path());
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
assert_eq!(source.api.transport().call_count(), 0);
}
#[test]
fn a_page_already_covered_by_the_cache_never_reaches_the_server() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{},{}]", status_line(1), status_line(2)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let calls_before = source.api.transport().call_count();
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
panic!("a cursor of 2 is a real question about the conversation");
};
assert_eq!(page.len(), 1);
assert_eq!(page[0].seq, 1);
assert_eq!(
source.api.transport().call_count(),
calls_before,
"a cache hit must not touch the network"
);
}
#[test]
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(5)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(3)));
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
source2.page(5, 10, true).unwrap();
assert_eq!(
source2.api.transport().calls.lock().unwrap()[0],
"/sessions/s1/transcript?limit=10&before=5&coalesce=true"
);
}
#[test]
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let lines: Vec<String> = (3..6).map(status_line).collect();
assert!(cache.store_page(&lines, 3, 6, true));
cache.append(&status_line(6), 6);
cache.append(&status_line(7), 7);
cache.flush();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(9)));
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
source.page(10, 10, true).unwrap();
assert_eq!(
source.api.transport().calls.lock().unwrap()[0],
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
"the fetch must stop one seq below where this phone's copy ends"
);
}
#[test]
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(500, "server on fire");
let source = source(transport, dir.path());
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
}
#[test]
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.store_page(
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
3,
4,
true,
);
cache.append(&status_line(4), 4);
cache.flush();
let transport = ScriptedTransport::default();
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
assert_eq!(
source.api.transport().call_count(),
0,
"a cache hit that cannot be read must not fall through to the server unnoticed"
);
}
#[test]
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.append("not json at all", 1);
cache.flush();
let transport = ScriptedTransport::default();
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
assert_eq!(source.cached_opening(80), None);
assert!(
source.cache.tail().is_none(),
"a damaged line purges the cache"
);
}
#[test]
fn follow_writes_events_to_the_cache_before_the_caller_sees_them() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("{}\n\n", sse_frame(&status_line(1))));
let source = source(transport, dir.path());
let mut seen = Vec::new();
source
.follow(0, |item| {
if let StreamItem::Event { event, .. } = item {
seen.push(event.seq);
}
true
})
.unwrap();
assert_eq!(seen, vec![1]);
assert_eq!(source.cache.tail().unwrap().seq, 1);
}
fn sse_frame(data: &str) -> String {
format!("data:{data}")
}
}
@@ -1,51 +1,6 @@
//! RUST.md's E4: a session list on the left, `transcript-ui`'s screen (I5)
//! filling the rest, both against a real `ai-server` reached through
//! `client-core`. The layout is the simplest thing that shows both at
//! once -- a fixed-width column and `rest(1)` for everything else, using
//! `iris::widget::{Span, WidgetPtr}` the way `tabs-ui` already switches
//! panes, rather than anything desktop-specific:
//!
//! ```text
//! +-----------+--------------------------------------+
//! | session | transcript_ui::TranscriptScreen |
//! | list | (List of folded rows + composer) |
//! | (WidgetPtr| |
//! | swapped | (WidgetPtr swapped whole on session |
//! | on data) | switch or a new transcript event) |
//! +-----------+--------------------------------------+
//! ```
//!
//! **Deliberately left simple, and why**: every incoming SSE event refolds
//! the *entire* transcript (`client_core::transcript_fold::fold_event` is
//! already `O(items)` and a desktop session's conversation is small) and
//! rebuilds the whole right-hand widget tree from scratch, rather than
//! reaching for `TranscriptScreen::push_row`'s incremental append.
//! `push_row` cannot update a row already on screen -- only append a new
//! one -- and a streaming assistant reply is exactly a row whose *text*
//! keeps changing after it first appears (see `transcript-ui`'s own doc on
//! `fold_event` folding deltas into one growing item). A full rebuild
//! shows that growth correctly at the cost of redrawing everything each
//! time; fine for this proof, wrong for a long, fast-streaming transcript
//! -- the incremental path that fixes it needs `transcript-ui` to expose
//! updating a row in place, which it does not yet. The composer's
//! in-progress text survives a rebuild (`rebuild_transcript`'s
//! `in_progress` local) since the user typing a followup while a reply
//! streams in is the one case a naive rebuild would otherwise lose data
//! on.
//!
//! Background network I/O (`client_core::api`/`event_stream`, both
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
//! `std::thread`s that report back through `winit`'s `EventLoopProxy`
//! (`Proxy<AppEvent>`), rather than through iris's own `Tasks`/`task_on`:
//! `Tasks` only requests a redraw once, after its whole async closure
//! finishes, which fits a single request-then-update but not a live SSE
//! loop that needs to be seen redrawing after *each* event it relays.
//! `Proxy::send_event` wakes the window's event loop immediately, once per
//! event, which is what a stream wants.
use client_core::api::{ApiClient, SessionSummary, UreqTransport};
use client_core::event_stream::{StreamItem, follow_session_events};
use client_core::transcript_fold::{
use crate::client::api::{ApiClient, SessionSummary, UreqTransport};
use crate::client::event_stream::{StreamItem, follow_session_events};
use crate::client::transcript_fold::{
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
};
use event_model::SeqEvent;
@@ -53,18 +8,8 @@ use iris::prelude::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
/// The session list column's width -- a fixed size for the simplest
/// layout that shows both panels at once (UI_RULES's text-truncation and
/// no-shrink rules apply to what's drawn inside it, not to this choice of
/// column width itself).
const LIST_WIDTH: f32 = 260.0;
/// Everything a background thread hands back to the window's event loop.
/// `generation` on the session-scoped variants is the generation
/// `select_session` was on when the thread started (`Client::generation`)
/// -- compared back against the current one before being applied, so a
/// slow response from a session the reader has since clicked away from
/// can't overwrite what replaced it.
enum AppEvent {
Sessions(Result<Vec<SessionSummary>, String>),
TranscriptLoaded {
@@ -93,13 +38,6 @@ pub fn run() {
struct Client {
ui_state: DefaultUiState,
api: Arc<ApiClient<UreqTransport>>,
/// A second, independent `UreqTransport` to the same server, used only
/// by `select_session`'s live-follow loop. `ApiClient` keeps its
/// transport private (rightly -- nothing outside it should reach past
/// the typed calls), so a caller that also needs the raw
/// `Transport::stream` for SSE, as this one does, builds its own
/// rather than the crate growing a getter whose only purpose would be
/// letting one caller reach around its own abstraction.
stream_transport: Arc<UreqTransport>,
proxy: Proxy<AppEvent>,
sessions: Vec<SessionSummary>,
@@ -107,9 +45,7 @@ struct Client {
items: Vec<TranscriptItem>,
list_ptr: WeakWidget<WidgetPtr>,
transcript_ptr: WeakWidget<WidgetPtr>,
screen: Option<transcript_ui::TranscriptScreen>,
/// Bumped every time the selected session changes; see `AppEvent`'s
/// doc for what it guards against.
screen: Option<crate::ui::TranscriptScreen>,
generation: Arc<AtomicU64>,
}
@@ -121,13 +57,7 @@ impl DefaultAppState for Client {
rsc: &mut DefaultRsc<Self>,
proxy: Proxy<AppEvent>,
) -> Self {
// Re-validated here rather than threaded through from `main` --
// `DefaultApp::run()` takes no payload, so there is no other way
// to get `main`'s parsed CLI/config into this constructor. `main`
// already called this once to fail fast before a window opens;
// this call only fails if the filesystem changed underneath the
// process in between, which is not a case worth a nicer message.
let (server, ca_pem) = crate::load_startup_config().unwrap_or_else(|e| {
let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| {
eprintln!("desktop-app: {e}");
std::process::exit(2);
});
@@ -209,8 +139,12 @@ impl DefaultAppState for Client {
event,
} => {
if self.current(&session_id, generation) {
let old_items = self.items.clone();
self.items = fold_event(&self.items, &event);
self.rebuild_transcript(rsc);
match &self.screen {
Some(screen) => screen.apply(rsc, &old_items, &self.items),
None => self.rebuild_transcript(rsc),
}
}
}
AppEvent::StreamEnded {
@@ -237,9 +171,6 @@ impl Client {
&& self.generation.load(Ordering::SeqCst) == generation
}
/// Replaces the right-hand panel with a line of text -- built before
/// `transcript_ptr` is reached for, since building the message and
/// swapping it in both need `rsc` and can't overlap as one borrow.
fn show_message(&mut self, rsc: &mut DefaultRsc<Self>, message: &str) {
let widget = placeholder(rsc, message);
(self.transcript_ptr)(rsc).set(widget);
@@ -262,17 +193,12 @@ impl Client {
list(rsc).push(row);
}
let tree = list
.background(rect(Color::rgb(24, 24, 28)))
.background(rect(Srgba8::rgb(24, 24, 28)))
.add_strong(rsc)
.any();
(self.list_ptr)(rsc).set(tree);
}
/// Selecting a session starts a fresh generation: any thread still
/// working for the previous one checks `Client::current` before
/// touching state, so a slow response for a session the reader has
/// clicked away from is silently dropped rather than overwriting what
/// replaced it.
fn select_session(&mut self, rsc: &mut DefaultRsc<Self>, session_id: String) {
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
self.selected = Some(session_id.clone());
@@ -286,24 +212,9 @@ impl Client {
let proxy = self.proxy.clone();
let live_generation = self.generation.clone();
std::thread::spawn(move || {
// The most recent 200 events, coalesced -- plenty for a
// desktop proof; RUST.md's I3/history-paging work is what a
// real scrollback would reuse, out of scope here (E4 is only
// "the same screen runs in a window").
let page: Result<Vec<serde_json::Value>, String> = api
.fetch_transcript_page(&session_id, None, 200, true)
.map_err(|e| e.to_string());
// The raw wire `seq` of the last line fetched -- not the seq of
// the last *folded item*. A `TranscriptItem::AssistantMsg` keeps
// the seq of the first delta it accumulated (`fold_event`'s own
// doc: "a row whose identity changed with every delta would be
// a new row every frame"), so resuming the live stream from
// that seq re-delivers every delta already folded into it,
// duplicating the tail of whatever reply was mid-stream when
// the page was fetched. Found by screenshotting a real reply
// through `run-headless.sh`: the assistant's line read "You
// said: ... testsaid: ... test", the back half being deltas 2
// through N replayed onto an already-complete message.
let after = page
.as_ref()
.ok()
@@ -316,9 +227,6 @@ impl Client {
result,
});
// Follows live from here in the same thread -- sequential
// rather than a second thread, since there is nothing to do
// with the stream until the page above has been sent anyway.
let stop = || live_generation.load(Ordering::SeqCst) != generation;
if stop() {
return;
@@ -364,7 +272,7 @@ impl Client {
.filter(|t| !t.is_empty());
let rows = group_tool_runs(&self.items);
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
let (screen, tree) = crate::ui::build_tree(rsc, rows);
if let Some(text) = in_progress {
screen.composer.field.edit(rsc).set(&text);
@@ -385,22 +293,20 @@ impl Client {
}
}
/// One row in the session list: title on top, status below, highlighted
/// when it's the one currently shown.
fn session_row(
rsc: &mut DefaultRsc<Client>,
session: &SessionSummary,
selected: bool,
) -> StrongWidget {
let bg = if selected {
Color::rgb(58, 90, 138)
Srgba8::rgb(58, 90, 138)
} else {
Color::rgb(38, 38, 44)
Srgba8::rgb(38, 38, 44)
};
let id = session.id.clone();
let label = format!("{}\n{}", session.title, session.status);
wtext(label)
.color(Color::WHITE)
.color(PaintId::WHITE)
.wrap(true)
.pad(10)
.width(rest(1))
@@ -417,7 +323,7 @@ fn session_row(
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(Color::WHITE)
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
+16
View File
@@ -0,0 +1,16 @@
use crate::client::config::EnrollmentStore;
use std::path::PathBuf;
pub fn config_dir() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").expect("HOME must be set");
PathBuf::from(home).join(".config")
});
base.join("ai-app-desktop")
}
pub fn store() -> EnrollmentStore {
EnrollmentStore::new(config_dir())
}
+3
View File
@@ -0,0 +1,3 @@
pub mod app;
pub mod config;
pub mod startup;
+70
View File
@@ -0,0 +1,70 @@
//! The desktop binary's command line and the enrolment it resolves --
//! `--link`/`--ca`, parsed once at startup and again from `app.rs`'s
//! `Client::new`. Here rather than in `src/bin_desktop.rs` because both
//! callers are in the library; the binary is only `fn main`.
use crate::client::config::EnrolledServer;
use super::config;
struct Args {
ca_path: Option<std::path::PathBuf>,
link: Option<String>,
}
fn parse_args() -> Result<Args, String> {
let mut ca_path = None;
let mut link = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--ca" => {
ca_path = Some(std::path::PathBuf::from(
args.next().ok_or("--ca needs a path")?,
))
}
"--link" => link = Some(args.next().ok_or("--link needs a value")?),
other => return Err(format!("unrecognised argument '{other}'")),
}
}
Ok(Args { ca_path, link })
}
pub fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
let args = parse_args()?;
let store = config::store();
let server = match args.link {
Some(link) => {
let server = EnrolledServer::parse_link(&link)?;
store
.save(&server)
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
server
}
None => store
.load()
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
.ok_or_else(|| {
format!(
"no server enrolled yet under {} -- pass --link 'aiapp://enroll?...' \
once (app/ui-sandbox.sh's start banner prints one)",
config::config_dir().display()
)
})?,
};
// `--ca` wins where it was given, so a caller can point a link's
// server at a certificate it did not carry -- and so the flag still
// means what it did before the link could carry one.
let ca_pem = match (&args.ca_path, &server.ca_pem) {
(Some(path), _) => std::fs::read(path)
.map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?,
(None, Some(pem)) => pem.clone().into_bytes(),
(None, None) => {
return Err("this enrollment carries no CA -- pass --ca PATH (e.g. \
~/.config/ai-app/certs/ca.pem), or enrol again with a link \
minted by a server that includes one"
.to_string());
}
};
Ok((server, ca_pem))
}
+20
View File
@@ -0,0 +1,20 @@
pub mod client;
#[cfg(feature = "screens")]
pub mod ui;
#[cfg(all(feature = "screens", not(target_os = "android")))]
pub mod desktop;
#[cfg(all(feature = "screens", target_os = "android"))]
pub mod android;
// `jni` 0.22's `native_method!` expands to `AtomicBool::fetch_update`,
// which this toolchain deprecates in favour of `try_update`. The call is
// inside the macro, so there is nothing here to migrate -- the fix is a
// `jni` release, and this allow comes out when one lands. Scoped to the
// module the macro is used in rather than the crate, so a deprecation in
// our own code is still a warning.
#[cfg(feature = "shell")]
#[allow(deprecated)]
pub mod shell;
+99
View File
@@ -0,0 +1,99 @@
use jni::Env;
use jni::errors::Result;
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
use jni::refs::{Global, LoaderContext};
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
use jni::strings::JNIString;
use std::sync::OnceLock;
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
if CLASS_LOADER.get().is_some() {
return Ok(());
}
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
let loader_obj = call_method(
env,
&class_obj,
"getClassLoader",
"()Ljava/lang/ClassLoader;",
&[],
)?
.l()?;
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
let global = env.new_global_ref(&loader)?;
// Lost the race with another entry point calling this concurrently --
// both loaders name the same app, so either one is fine and there is
// nothing to reconcile.
let _ = CLASS_LOADER.set(global);
Ok(())
}
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
/// through the cached app classloader when one has been remembered, and
/// through the ordinary default otherwise -- which is every call made
/// before any entry point has run, and is also correct for a main-thread
/// caller, so there is no case this makes worse.
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
match CLASS_LOADER.get() {
Some(loader) => {
let binary_name = name.replace('/', ".");
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
}
None => env.find_class(JNIString::new(name)),
}
}
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
resolve_class(env, name)
}
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
Ok(env.new_string(text)?.into())
}
pub fn new_object<'local>(
env: &mut Env<'local>,
class: &str,
sig: &str,
args: &[JValue],
) -> Result<JObject<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.new_object(class, sig.method_signature(), args)
}
pub fn call_method<'local>(
env: &mut Env<'local>,
obj: &JObject,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
}
pub fn call_static_method<'local>(
env: &mut Env<'local>,
class: &str,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
}
pub fn get_static_field<'local>(
env: &mut Env<'local>,
class: &str,
field: &str,
sig: &str,
) -> Result<JValueOwned<'local>> {
let sig = RuntimeFieldSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.get_static_field(class, JNIString::new(field), sig.field_signature())
}
@@ -1,23 +1,3 @@
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
//! package comment for what this crate is and RUST.md's E3 entry for the
//! design decisions.
//!
//! Each native method is declared with `jni`'s [`native_method!`] macro
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
//! the macro derives the mangled export name and the JNI signature from the
//! Rust function itself, so the two cannot drift apart the way a
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
//! routinely do. `error_policy = LogErrorAndDefault` matches
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
//! JNI call that threw) is reported to logcat, not thrown back into Java
//! as an exception that would crash the app over something recoverable.
//!
//! Each `const _: NativeMethod = native_method! { ... };` binding is
//! otherwise unused by name -- `_` is the idiomatic way to keep a
//! side-effecting const (here, generating the `#[export_name]`d function
//! the JVM resolves by the JNI naming convention) without a `dead_code`
//! warning for a binding nothing reads.
mod jcall;
mod notify;
mod settings;
@@ -28,15 +8,6 @@ use jni::objects::{JClass, JObject};
use jni::sys::jint;
use jni::{Env, NativeMethod, native_method};
/// Installs the `log` backend that routes to logcat, once per process.
/// Without it, `LogErrorAndDefault` (every native method below) and any
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
/// call into the `log` facade's default no-op logger, and a real failure
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
/// crashing, since nothing on screen or in the log says a notification was
/// dropped. Called from every entry point below rather than a Java-side
/// `Application.onCreate`, since this crate deliberately has no such class
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
fn ensure_logger() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
@@ -65,8 +36,6 @@ const _: NativeMethod = native_method! {
error_policy = LogErrorAndDefault,
};
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
fn native_handle_intent<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
@@ -84,9 +53,6 @@ const _: NativeMethod = native_method! {
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
/// enrollment may have just landed) and from `NotificationService.sync`
/// itself. See `notify::sync`.
fn native_sync<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
@@ -103,7 +69,6 @@ const _: NativeMethod = native_method! {
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
fn native_on_start_command<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
@@ -120,7 +85,6 @@ const _: NativeMethod = native_method! {
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
fn native_on_destroy<'local>(
_env: &mut Env<'local>,
_class: JClass<'local>,
@@ -12,20 +12,19 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use client_core::api::UreqTransport;
use client_core::notifications::{SessionNotification, follow_notifications};
use crate::client::api::UreqTransport;
use crate::client::notifications::{SessionNotification, follow_notifications};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JValue};
use jni::sys::{JNI_TRUE, jint};
use crate::settings::{self, ServerSettings};
use crate::shell::settings::{self, ServerSettings};
const ALERT_CHANNEL: &str = "sessions";
const ONGOING_CHANNEL: &str = "connection";
const ONGOING_ID: i32 = 1;
const ALERT_ID: i32 = 2;
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
/// Whether the follow-loop thread is already running. **A deviation from
@@ -45,25 +44,14 @@ const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
/// same guard back to `Notifications.kt` separately.
static RUNNING: AtomicBool = AtomicBool::new(false);
/// Set by `nativeOnDestroy`, checked by the follow loop between
/// reconnects. **Known gap, recorded rather than hidden**: unlike
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
/// can interrupt a `ureq` read already blocked inside one connection --
/// `Transport::stream` hands back a plain `Read` with no cancellation
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
/// practice this is a bounded wait rather than a hang; closing that gap
/// for real means adding a cancellation point to `client_core::Transport`,
/// which is a decision affecting every caller of that trait, not just this
/// one -- left for whoever next depends on prompt shutdown.
static STOPPING: AtomicBool = AtomicBool::new(false);
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
crate::jcall::get_static_field(env, class, field, "I")?.i()
crate::shell::jcall::get_static_field(env, class, field, "I")?.i()
}
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
crate::jcall::call_static_method(
crate::shell::jcall::call_static_method(
env,
"androidx/core/app/NotificationManagerCompat",
"from",
@@ -80,22 +68,22 @@ fn create_channel(
name: &str,
importance: i32,
) -> Result<()> {
let id_j = crate::jcall::jstr_obj(env, id)?;
let builder = crate::jcall::new_object(
let id_j = crate::shell::jcall::jstr_obj(env, id)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationChannelCompat$Builder",
"(Ljava/lang/String;I)V",
&[JValue::Object(&id_j), JValue::Int(importance)],
)?;
let name_j = crate::jcall::jstr_obj(env, name)?;
crate::jcall::call_method(
let name_j = crate::shell::jcall::jstr_obj(env, name)?;
crate::shell::jcall::call_method(
env,
&builder,
"setName",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
&[JValue::Object(&name_j)],
)?;
let channel = crate::jcall::call_method(
let channel = crate::shell::jcall::call_method(
env,
&builder,
"build",
@@ -103,7 +91,7 @@ fn create_channel(
&[],
)?
.l()?;
crate::jcall::call_method(
crate::shell::jcall::call_method(
env,
manager,
"createNotificationChannel",
@@ -146,8 +134,8 @@ fn new_intent_for<'l>(
context: &JObject,
class_name: &str,
) -> Result<JObject<'l>> {
let target_class = crate::jcall::find_class(env, class_name)?;
crate::jcall::new_object(
let target_class = crate::shell::jcall::find_class(env, class_name)?;
crate::shell::jcall::new_object(
env,
"android/content/Intent",
"(Landroid/content/Context;Ljava/lang/Class;)V",
@@ -165,41 +153,42 @@ fn session_intent<'l>(
session_id: &str,
) -> Result<JObject<'l>> {
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
crate::jcall::call_method(
let action_view = crate::shell::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
crate::shell::jcall::call_method(
env,
&intent,
"setAction",
"(Ljava/lang/String;)Landroid/content/Intent;",
&[JValue::Object(&action_view)],
)?;
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
crate::jcall::call_method(
let builder = crate::shell::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
let scheme = crate::shell::jcall::jstr_obj(env, settings::SCHEME)?;
crate::shell::jcall::call_method(
env,
&builder,
"scheme",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&scheme)],
)?;
let authority = crate::jcall::jstr_obj(env, "session")?;
crate::jcall::call_method(
let authority = crate::shell::jcall::jstr_obj(env, "session")?;
crate::shell::jcall::call_method(
env,
&builder,
"authority",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&authority)],
)?;
let path = crate::jcall::jstr_obj(env, session_id)?;
crate::jcall::call_method(
let path = crate::shell::jcall::jstr_obj(env, session_id)?;
crate::shell::jcall::call_method(
env,
&builder,
"appendPath",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&path)],
)?;
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
crate::jcall::call_method(
let uri = crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?
.l()?;
crate::shell::jcall::call_method(
env,
&intent,
"setData",
@@ -216,7 +205,7 @@ fn pending_activity<'l>(
) -> Result<JObject<'l>> {
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
crate::jcall::call_static_method(
crate::shell::jcall::call_static_method(
env,
"android/app/PendingIntent",
"getActivity",
@@ -238,7 +227,7 @@ fn builder_call<'l>(
sig: &str,
args: &[JValue],
) -> Result<()> {
crate::jcall::call_method(env, builder, method, sig, args)?;
crate::shell::jcall::call_method(env, builder, method, sig, args)?;
Ok(())
}
@@ -259,14 +248,14 @@ fn foreground_type(env: &mut Env) -> Result<i32> {
}
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
let builder = crate::jcall::new_object(
let channel = crate::shell::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
let title = crate::shell::jcall::jstr_obj(env, "Watching for sessions that need you")?;
builder_call(
env,
&builder,
@@ -297,7 +286,8 @@ fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObj
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(priority_min)],
)?;
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l()
crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
.l()
}
/// Starts the service if there is a server to connect to, and stops it
@@ -306,7 +296,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
let service_intent =
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
if settings::load(env, context)?.is_none() {
crate::jcall::call_method(
crate::shell::jcall::call_method(
env,
context,
"stopService",
@@ -316,7 +306,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
return Ok(());
}
create_channels(env, context)?;
crate::jcall::call_static_method(
crate::shell::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"startForegroundService",
@@ -326,16 +316,11 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
Ok(())
}
/// The `Service.onStartCommand` body -- loads settings, starts the
/// foreground notification, and spawns the follow-loop thread. Answers the
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
/// framework rather than hardcoded so a wrong guess at their values cannot
/// silently pick the other behaviour.
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
match try_start(env, &service) {
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
Ok(false) => {
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
let _ = crate::shell::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
}
Err(e) => {
@@ -352,7 +337,7 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
let ca = settings::load_pinned_ca(env)?;
let notification = ongoing_notification(env, service)?;
let fg_type = foreground_type(env)?;
crate::jcall::call_static_method(
crate::shell::jcall::call_static_method(
env,
"androidx/core/app/ServiceCompat",
"startForeground",
@@ -379,9 +364,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
std::thread::Builder::new()
.name("ai-app-notifications".to_string())
.spawn(move || {
// Requests a *permanent* attachment (detached only when this thread
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
// this is the long-lived follow loop, not a one-shot callback.
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
follow_loop(env, &context, settings, &ca);
Ok(())
@@ -391,12 +373,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
Ok(true)
}
/// Follows the backend's notification stream, reconnecting until stopped
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
/// ordinary case, so it retries quietly and forever; nothing is shown when
/// it cannot connect, for the same reason as the Kotlin original: a
/// notification saying "I could not tell you whether anything happened" is
/// noise about a condition nobody can act on.
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
while !STOPPING.load(Ordering::SeqCst) {
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
@@ -414,9 +390,6 @@ fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &
}
}
/// One notification per session, replacing that session's previous one --
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
/// branches this module's doc comment explains.
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
let manager = notification_manager(env, context)?;
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
@@ -424,13 +397,14 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
let allowed = if sdk < tiramisu {
true
} else {
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
let permission =
crate::shell::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
let granted = static_int(
env,
"android/content/pm/PackageManager",
"PERMISSION_GRANTED",
)?;
let result = crate::jcall::call_static_method(
let result = crate::shell::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"checkSelfPermission",
@@ -441,20 +415,21 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
result == granted
};
let enabled =
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
crate::shell::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?
.z()?;
if !allowed || !enabled {
return Ok(());
}
let intent = session_intent(env, context, &notification.session_id)?;
let pending = pending_activity(env, context, &intent)?;
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
let builder = crate::jcall::new_object(
let channel = crate::shell::jcall::jstr_obj(env, ALERT_CHANNEL)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::jcall::jstr_obj(env, &notification.title)?;
let title = crate::shell::jcall::jstr_obj(env, &notification.title)?;
builder_call(
env,
&builder,
@@ -462,7 +437,7 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?;
let text = crate::shell::jcall::jstr_obj(env, notification.kind.attention_line())?;
builder_call(
env,
&builder,
@@ -507,11 +482,16 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let built =
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
.l()?;
let tag = crate::jcall::jstr_obj(env, &notification.session_id)?;
crate::jcall::call_method(
let built = crate::shell::jcall::call_method(
env,
&builder,
"build",
"()Landroid/app/Notification;",
&[],
)?
.l()?;
let tag = crate::shell::jcall::jstr_obj(env, &notification.session_id)?;
crate::shell::jcall::call_method(
env,
&manager,
"notify",
@@ -525,8 +505,6 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
Ok(())
}
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
/// the gap this module's `STOPPING` doc explains.
pub fn on_destroy() {
STOPPING.store(true, Ordering::SeqCst);
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
@@ -542,9 +520,9 @@ pub fn on_destroy() {
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
let message = format!("android-shell: {where_}: {error}");
let _ = (|| -> Result<()> {
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
let msg = crate::jcall::jstr_obj(env, &message)?;
crate::jcall::call_static_method(
let tag = crate::shell::jcall::jstr_obj(env, "android-shell")?;
let msg = crate::shell::jcall::jstr_obj(env, &message)?;
crate::shell::jcall::call_static_method(
env,
"android/util/Log",
"e",
@@ -28,20 +28,15 @@ impl ServerSettings {
}
}
/// This experiment's own scheme and Keystore alias -- distinct from the
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
/// installed side by side on the same development device without
/// colliding over which one a scanned QR or a deep link resolves to. See
/// RUST.md's E3 entry for why they are not the same value.
pub(crate) const SCHEME: &str = "aiappshell";
const KEY_ALIAS: &str = "aiapp-shell-token-key";
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
crate::jcall::new_object(
let scheme = crate::shell::jcall::jstr_obj(env, SCHEME)?;
let alias = crate::shell::jcall::jstr_obj(env, KEY_ALIAS)?;
crate::shell::jcall::new_object(
env,
STORE_CLASS,
"(Ljava/lang/String;Ljava/lang/String;)V",
@@ -51,13 +46,14 @@ fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
let host = get_string(env, settings_obj, "getHost")?;
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
let port = crate::shell::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
let token = get_string(env, settings_obj, "getToken")?;
Ok(ServerSettings { host, port, token })
}
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
let value =
crate::shell::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
let jstr: JString = env.cast_local::<JString>(value)?;
jstr.try_to_string(env)
}
@@ -66,7 +62,7 @@ fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
/// `ServerConfig.kt`'s `loadServerSettings`.
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::jcall::call_method(
let settings_obj = crate::shell::jcall::call_method(
env,
&store,
"load",
@@ -80,12 +76,11 @@ pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>>
Ok(Some(read_settings(env, &settings_obj)?))
}
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
let store = new_store(env)?;
let host = crate::jcall::jstr_obj(env, &settings.host)?;
let token = crate::jcall::jstr_obj(env, &settings.token)?;
let settings_obj = crate::jcall::new_object(
let host = crate::shell::jcall::jstr_obj(env, &settings.host)?;
let token = crate::shell::jcall::jstr_obj(env, &settings.token)?;
let settings_obj = crate::shell::jcall::new_object(
env,
SETTINGS_CLASS,
"(Ljava/lang/String;ILjava/lang/String;)V",
@@ -95,7 +90,7 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
JValue::Object(&token),
],
)?;
crate::jcall::call_method(
crate::shell::jcall::call_method(
env,
&store,
"save",
@@ -105,12 +100,9 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
Ok(())
}
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
/// query-parameter rules rather than re-deriving them here.
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::jcall::call_method(
let settings_obj = crate::shell::jcall::call_method(
env,
&store,
"parseEnrollmentUri",
@@ -129,7 +121,7 @@ pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<Serve
/// but into a plain Java constant, since this module has no Kotlin of its
/// own to generate into.
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
let value = crate::jcall::get_static_field(
let value = crate::shell::jcall::get_static_field(
env,
"com/example/aiapp/shell/PinnedCa",
"PINNED_CA_PEM",
@@ -1,30 +1,10 @@
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
//!
//! **Scope cut, recorded rather than silent**: only shared *text*
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
//! ported here, because `client-core`'s `ApiClient` does not have the
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
//! `content://` stream and no text falls through to a toast saying so,
//! rather than silently doing nothing. Closing this gap is the same
//! `client-core` work whichever caller needs it next.
//!
//! **Which session a share lands in** is also a placeholder: with no
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
//! to whichever session has the latest `last_activity` -- the one most
//! likely to be what somebody meant. Worth revisiting once a real screen
//! exists to ask instead of guessing.
use client_core::api::{ApiClient, UreqTransport};
use crate::client::api::{ApiClient, UreqTransport};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
use crate::notify;
use crate::settings;
use crate::shell::notify;
use crate::shell::settings;
const ACTION_SEND: &str = "android.intent.action.SEND";
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
@@ -32,7 +12,8 @@ const ACTION_VIEW: &str = "android.intent.action.VIEW";
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
let value =
crate::shell::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
if value.is_null() {
return Ok(None);
}
@@ -41,8 +22,8 @@ fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Optio
}
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
let message = crate::jcall::jstr_obj(env, message)?;
crate::jcall::call_static_method(
let message = crate::shell::jcall::jstr_obj(env, message)?;
crate::shell::jcall::call_static_method(
env,
"com/example/aiapp/shell/MainActivity",
"toast",
@@ -52,8 +33,6 @@ fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
Ok(())
}
/// The one place an incoming intent is sorted into what it means -- mirrors
/// `MainActivity.kt`'s `handleIntent`.
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let action = get_string_method(env, intent, "getAction")?;
if matches!(
@@ -65,7 +44,8 @@ pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Res
if action.as_deref() != Some(ACTION_VIEW) {
return Ok(());
}
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
let uri = crate::shell::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?
.l()?;
if uri.is_null() {
return Ok(());
}
@@ -84,9 +64,6 @@ fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Resu
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
return Ok(());
};
// There is no session screen yet (E4's job); the toast is this
// experiment's stand-in proof that the tap was routed to the right
// session id.
toast(env, activity, &format!("Opened session {session_id}"))
}
@@ -105,12 +82,9 @@ fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result
}
}
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
/// what happens to it, minus attachments per this module's doc comment.
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
let text = crate::jcall::call_method(
let extra_text = crate::shell::jcall::jstr_obj(env, EXTRA_TEXT)?;
let text = crate::shell::jcall::call_method(
env,
intent,
"getStringExtra",
+74
View File
@@ -0,0 +1,74 @@
use crate::ui::theme::Theme;
use iris::prelude::*;
const MAX_LINES: f32 = 6.0;
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
const FIELD_PAD_DP: f32 = 12.0;
/// `field` is exposed so the caller can read its content on submit
/// (`field.edit(rsc).text()`) and clear it afterward
/// (`field.edit(rsc).set("")`).
pub struct Composer {
pub field: WeakWidget<TextEdit>,
/// The bar's own outer padding -- only `bottom` is ever changed, by
/// [`Self::set_bottom_inset`]. A `Pad` around the whole bar rather than
/// a rebuilt tree, because `field` lives inside it and cannot be
/// re-added to a new wrapper once it is strongly owned here.
outer_pad: WeakWidget<Pad>,
}
impl Composer {
/// Called by the platform shell (Android's `on_insets_changed`, e.g.)
/// whenever the space below the bar changes: the IME's own inset while
/// it is open, the navigation-bar inset otherwise. Takes a plain
/// `f32` in the caller's own physical-pixel units rather than an
/// Android-specific insets type, so this crate stays usable from the
/// winit backend too, which has no navigation bar to report.
/// Rewrites the existing `Pad` in place (marking it dirty through the
/// ordinary `Widgets::get_mut` path) instead of swapping in a new one,
/// so the field's focus, selection and in-progress text are untouched.
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
pad.padding.bottom = Len::abs(inset);
pad.exact_region = true;
}
}
}
/// Returns the composer plus its own bar as a **weak** id -- the caller
/// (`lib.rs::build`) embeds it in the screen's own top-level tuple, whose
/// `set_root` performs the one real strong registration. Calling
/// `.add_strong`/`.upgrade` a second time on an id already strong-owned
/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same
/// mistake this box's `row.rs` first made with its sender-label header, see
/// that file's comment for the fuller account.
pub fn build_composer<Rsc: HasEvents>(rsc: &mut Rsc, theme: &Theme) -> (Composer, WeakWidget)
where
Rsc::State: FocusHost,
{
let field = wtext("")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(true)
.size(18)
.color(theme.text.clone())
.attr::<Selectable>(())
.label("Message")
.add(rsc);
// Without any mask at all the overflow paints *above* the bar, over
// the transcript: measured at 58px of stray text for a 475px message
// in a 417px box.
let content = field
.width(rest(1))
.scrollable(Axis::Y, Pin::End)
.pad(dp(FIELD_PAD_DP))
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
.width(rest(1))
.masked_by(rect(theme.composer_surface.clone()))
.add(rsc);
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
(Composer { field, outer_pad }, outer_pad)
}
+129
View File
@@ -0,0 +1,129 @@
use crate::client::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
use event_model::SeqEvent;
use iris::prelude::*;
pub const BACKLOG_COUNT: usize = 3202;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
pub const PHONE_WIDTH: f32 = 1080.0;
pub const PHONE_HEIGHT: f32 = 2424.0;
pub const PHONE_SCALE: f32 = 2.55;
pub const PHONE_FRAME_MS: u64 = 8;
pub fn phone_size() -> Vec2 {
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
}
pub struct Fixture {
pub backlog: Vec<serde_json::Value>,
pub stream_tail: Vec<SeqEvent>,
}
impl Fixture {
/// Parses the whole fixture. Panics on malformed input: this is a
/// generated file compiled into the binary, so a parse failure is a
/// broken build rather than a condition a caller could recover from
/// (CODE_RULES: separate recoverable conditions from programmer
/// error).
pub fn parse() -> Self {
let mut backlog = Vec::with_capacity(BACKLOG_COUNT);
let mut stream_tail = Vec::new();
for (i, line) in FIXTURE_JSONL
.lines()
.filter(|line| !line.trim().is_empty())
.enumerate()
{
let value: serde_json::Value =
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
if i < BACKLOG_COUNT {
backlog.push(value);
} else {
stream_tail.push(
serde_json::from_value(value)
.expect("bench fixture event matches event-model's SeqEvent"),
);
}
}
Self {
backlog,
stream_tail,
}
}
/// The opening page folded into transcript items -- the same
/// `fold_page` a real first load runs. `Err` carries the fold's own
/// message, which a caller shows on screen rather than panicking, so
/// a fixture that stops folding is visible in the app instead of
/// being a crash on launch.
pub fn backlog_items(&self) -> Result<Vec<TranscriptItem>, String> {
fold_page(&self.backlog)
}
}
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
group_tool_runs(items)
}
/// Everything a caller needs to run the fixture as an app screen would:
/// the screen, the folded items behind it, and the events not yet
/// streamed. The tree itself comes back separately from
/// [`build_screen`], since whoever takes it owns it.
pub struct Opened {
pub screen: crate::ui::TranscriptScreen,
pub items: Vec<TranscriptItem>,
/// The tail, for a caller that goes on replaying it one event at a
/// time through `fold_event`/`TranscriptScreen::apply` -- the
/// streaming phase of either app's benchmark.
pub stream_tail: Vec<SeqEvent>,
}
/// Build the transcript screen over the fixture's opening page, without
/// claiming the window's root -- `crate::ui::build_tree`'s own split,
/// for a caller (the Android bench) that puts the screen inside a shell
/// of its own.
pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String>
where
Rsc::State: FocusHost + OpenUrl,
{
let fixture = Fixture::parse();
let items = fixture.backlog_items()?;
let (screen, tree) = crate::ui::build_tree(rsc, rows(&items));
Ok((
Opened {
screen,
items,
stream_tail: fixture.stream_tail,
},
tree,
))
}
pub fn open<Rsc: HasEvents>(
rsc: &mut Rsc,
ui_state: &mut impl HasRoot<Rsc>,
) -> Result<Opened, String>
where
Rsc::State: FocusHost + OpenUrl,
{
let (opened, tree) = build_screen(rsc)?;
ui_state.set_root(rsc, tree);
Ok(opened)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_fixture_has_a_backlog_and_a_streaming_tail() {
let fixture = Fixture::parse();
assert_eq!(fixture.backlog.len(), BACKLOG_COUNT);
assert!(
fixture.stream_tail.len() >= 400,
"the stream phase replays 400 events; the fixture has {}",
fixture.stream_tail.len()
);
assert!(!fixture.backlog_items().expect("the page folds").is_empty());
}
}
+644
View File
@@ -0,0 +1,644 @@
use crate::client::highlight::{self, Kind, Language};
use crate::client::markdown_blocks::{Block, BlockKind};
use crate::ui::theme::Theme;
use iris::prelude::*;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::ops::Range;
fn syntax_color(kind: Kind, theme: &Theme) -> PaintId {
match kind {
Kind::Keyword => theme.syntax_keyword.clone(),
Kind::String => theme.syntax_string.clone(),
Kind::Literal => theme.syntax_literal.clone(),
Kind::Comment => theme.syntax_comment.clone(),
Kind::Metadata => theme.syntax_metadata.clone(),
Kind::Punctuation => theme.syntax_punctuation.clone(),
Kind::Mark => theme.syntax_mark.clone(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockFrame {
Plain,
Verbatim { fill: PaintId },
Quote,
}
pub fn frame_of(kind: BlockKind, theme: &Theme) -> BlockFrame {
match kind {
BlockKind::Code => BlockFrame::Verbatim {
fill: theme.verbatim_surface.clone(),
},
BlockKind::Table => BlockFrame::Verbatim {
fill: theme.table_surface.clone(),
},
BlockKind::Quote => BlockFrame::Quote,
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
BlockFrame::Plain
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Link {
pub range: Range<usize>,
pub url: String,
}
#[derive(Clone, Default)]
pub struct Rendered {
pub text: String,
pub spans: Vec<SpanStyle>,
pub links: Vec<Link>,
}
impl Rendered {
pub fn link_at(&self, byte: usize) -> Option<&Link> {
self.links.iter().find(|l| l.range.contains(&byte))
}
}
/// The heading ladder, in points at a 16pt body: it starts near the body
/// text and descends, because these are headings inside a chat message
/// rather than the top of a document. The numbers are Material's
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
/// -- kept as literals rather than derived from `base_size` so the two
/// apps agree exactly.
fn heading_size(level: HeadingLevel) -> f32 {
match level {
HeadingLevel::H1 => 24.0,
HeadingLevel::H2 => 22.0,
HeadingLevel::H3 => 16.0,
HeadingLevel::H4 => 14.0,
HeadingLevel::H5 => 12.0,
HeadingLevel::H6 => 11.0,
}
}
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
/// A block-level separator inside one block's own text (a list item's
/// paragraphs, a quote's): two never run into each other with no gap, but
/// an empty `out` gets no leading blank.
fn ensure_blank_line(out: &mut String) {
if !out.is_empty() && !out.ends_with("\n\n") {
while out.ends_with('\n') {
out.pop();
}
out.push_str("\n\n");
}
}
fn ensure_line(out: &mut String) {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
}
pub fn render_block(block: &Block, base_size: f32, theme: &Theme) -> Rendered {
match block.kind {
BlockKind::Table => table_text(&block.source, theme),
_ => render_markdown(&block.source, base_size, theme),
}
}
/// One markdown source string rendered into plain text plus the spans that
/// style it. `base_size` is the row's ordinary paragraph font size, needed
/// only so a heading's override is relative to it rather than a hardcoded
/// absolute the caller cannot retune.
pub fn render_markdown(src: &str, base_size: f32, theme: &Theme) -> Rendered {
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
let mut out = String::new();
let mut spans = Vec::new();
let mut links = Vec::new();
// Stack of start byte offsets for whatever inline/block styling is
// currently open -- pulldown-cmark's `Start`/`End` events are always
// balanced and each `End` already names its own kind (`TagEnd`), so a
// plain offset stack (rather than a tree, or repeating the kind here
// too) is enough. A link's destination rides along beside its offset,
// since `TagEnd::Link` does not carry it.
let mut open: Vec<(usize, Option<String>)> = Vec::new();
// One entry per open list: `Some(next number)` for an ordered list,
// `None` for a bulleted one. Depth is this vector's length, which is
// what picks the bullet glyph.
let mut lists: Vec<Option<u64>> = Vec::new();
let mut fence_language: Option<Language> = None;
let parser = Parser::new_ext(src, options());
for event in parser {
match event {
Event::Start(tag) => match tag {
Tag::Heading { .. }
| Tag::Emphasis
| Tag::Strong
| Tag::Strikethrough
| Tag::Image { .. } => open.push((out.len(), None)),
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
Tag::CodeBlock(kind) => {
fence_language = match &kind {
CodeBlockKind::Fenced(info) => {
highlight::fence_language(info.split_whitespace().next())
}
CodeBlockKind::Indented => None,
};
ensure_blank_line(&mut out);
open.push((out.len(), None));
}
Tag::Item => {
ensure_line(&mut out);
let depth = lists.len().max(1);
out.push_str(&" ".repeat(depth - 1));
let start = out.len();
match lists.last_mut() {
Some(Some(n)) => {
out.push_str(&format!("{n}. "));
*n += 1;
}
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
}
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
}
Tag::List(first) => lists.push(first),
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
_ => {}
},
Event::End(
tag_end @ (TagEnd::Heading(_)
| TagEnd::Emphasis
| TagEnd::Strong
| TagEnd::Strikethrough
| TagEnd::Link
| TagEnd::Image
| TagEnd::CodeBlock),
) => {
let Some((start, dest)) = open.pop() else {
continue;
};
if matches!(tag_end, TagEnd::CodeBlock) {
while out.ends_with('\n') {
out.pop();
}
}
let range = start..out.len();
if range.is_empty() {
continue;
}
match tag_end {
TagEnd::Heading(level) => {
spans.push(SpanStyle::new(range).font_size(heading_size(level)).bold());
}
TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()),
TagEnd::Strong => spans.push(SpanStyle::new(range).bold()),
TagEnd::Strikethrough => {
spans.push(SpanStyle::new(range).color(theme.strikethrough.clone()));
}
// An image draws as its alt text until the port has a
// transcript image widget (IRIS_TODO's "scaled
// thumbnail"); marked as a link so it is at least
// followable rather than silently inert.
TagEnd::Link | TagEnd::Image => {
spans.push(
SpanStyle::new(range.clone())
.color(theme.link.clone())
.underline(),
);
if let Some(url) = dest {
links.push(Link { range, url });
}
}
TagEnd::CodeBlock => {
spans.push(
SpanStyle::new(range.clone())
.family(Family::Monospace)
.color(theme.code.clone()),
);
if let Some(language) = fence_language.take() {
highlight_into(&mut spans, &out, range, language, theme);
}
}
_ => unreachable!("filtered by the outer match arm"),
}
}
Event::Text(text) => out.push_str(&text),
Event::Code(text) => {
let start = out.len();
out.push_str(&text);
spans.push(
SpanStyle::new(start..out.len())
.family(Family::Monospace)
.color(theme.code.clone()),
);
}
Event::SoftBreak => out.push(' '),
Event::HardBreak => out.push('\n'),
Event::Rule => {
ensure_line(&mut out);
out.push_str("\u{2500}\u{2500}\u{2500}\n");
}
Event::TaskListMarker(done) => {
let start = out.len();
out.push_str(if done { "[x] " } else { "[ ] " });
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
}
Event::End(TagEnd::List(_)) => {
lists.pop();
}
_ => {}
}
}
while out.ends_with('\n') {
out.pop();
}
spans.retain(|s| s.range.end <= out.len());
links.retain(|l| l.range.end <= out.len());
Rendered {
text: out,
spans,
links,
}
}
fn options() -> Options {
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
}
pub(crate) fn highlight_into(
spans: &mut Vec<SpanStyle>,
text: &str,
range: Range<usize>,
language: Language,
theme: &Theme,
) {
let code = &text[range.clone()];
// char index -> byte offset within `code`, plus the end, so a span's
// `end` is always in range.
let bytes: Vec<usize> = code
.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(code.len()))
.collect();
for span in highlight::spans_of(code, language) {
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
debug_assert!(
false,
"highlight span {}..{} outside {} chars of code",
span.start,
span.end,
bytes.len() - 1
);
continue;
};
spans.push(
SpanStyle::new(range.start + start..range.start + end)
.family(Family::Monospace)
.color(syntax_color(span.kind, theme)),
);
}
}
const TABLE_MAX_COL: usize = 28;
pub fn table_text(src: &str, theme: &Theme) -> Rendered {
let rows = table_cells(src);
if rows.is_empty() {
return Rendered::default();
}
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
let wrapped: Vec<Vec<Vec<String>>> = rows
.iter()
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
.collect();
let widths: Vec<usize> = (0..columns)
.map(|c| {
wrapped
.iter()
.filter_map(|row| row.get(c))
.flat_map(|lines| lines.iter())
.map(|l| l.chars().count())
.max()
.unwrap_or(0)
})
.collect();
let mut out = String::new();
let mut spans = Vec::new();
for (r, row) in wrapped.iter().enumerate() {
let height = row.iter().map(Vec::len).max().unwrap_or(1);
let start = out.len();
for line in 0..height {
if !out.is_empty() {
out.push('\n');
}
for (c, width) in widths.iter().enumerate() {
if c > 0 {
out.push_str(" ");
}
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
let text = text.unwrap_or("");
out.push_str(text);
if c + 1 < widths.len() {
for _ in text.chars().count()..*width {
out.push(' ');
}
}
}
}
if r == 0 {
spans.push(SpanStyle::new(start..out.len()).bold());
out.push('\n');
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
let rule_start = out.len();
out.extend(std::iter::repeat_n('\u{2500}', rule));
spans.push(SpanStyle::new(rule_start..out.len()).color(theme.quote_bar.clone()));
}
}
Rendered {
text: out,
spans,
links: Vec::new(),
}
}
fn table_cells(src: &str) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut cell = String::new();
let mut in_cell = false;
for event in Parser::new_ext(src, options()) {
match event {
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
Event::Start(Tag::TableCell) => {
cell.clear();
in_cell = true;
}
Event::End(TagEnd::TableCell) => {
in_cell = false;
if let Some(row) = rows.last_mut() {
row.push(cell.trim().to_string());
}
}
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
_ => {}
}
}
rows.retain(|r| !r.is_empty());
rows
}
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let extra = if line.is_empty() { 0 } else { 1 };
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
lines.push(line);
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::markdown_blocks::split_blocks;
fn with_theme<T>(f: impl FnOnce(&Theme) -> T) -> T {
let mut paints = Paints::new();
let theme = Theme::new(&mut paints);
f(&theme)
}
fn render_markdown(src: &str, base_size: f32) -> Rendered {
with_theme(|theme| super::render_markdown(src, base_size, theme))
}
fn render_block(block: &Block, base_size: f32) -> Rendered {
with_theme(|theme| super::render_block(block, base_size, theme))
}
fn frame_of(kind: BlockKind) -> BlockFrame {
with_theme(|theme| super::frame_of(kind, theme))
}
fn syntax_color(kind: Kind) -> PaintId {
with_theme(|theme| super::syntax_color(kind, theme))
}
fn code_color() -> PaintId {
with_theme(|theme| theme.code.clone())
}
fn marker_color() -> PaintId {
with_theme(|theme| theme.marker.clone())
}
fn block(src: &str) -> Rendered {
let blocks = split_blocks(src);
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
render_block(&blocks[0], 16.0)
}
#[test]
fn plain_paragraph_has_no_spans() {
let r = render_markdown("just some words", 16.0);
assert_eq!(r.text, "just some words");
assert!(r.spans.is_empty());
}
#[test]
fn bold_and_italic_produce_spans_over_the_right_range() {
let r = render_markdown("a **bold** and *italic* word", 16.0);
assert_eq!(r.text, "a bold and italic word");
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
assert_eq!(&r.text[bold.range.clone()], "bold");
let italic = r.spans.iter().find(|s| s.italic).unwrap();
assert_eq!(&r.text[italic.range.clone()], "italic");
}
#[test]
fn heading_gets_a_bigger_font_size_span() {
let r = render_markdown("# A Title", 16.0);
assert!(r.text.starts_with("A Title"));
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
assert_eq!(&r.text[heading.range.clone()], "A Title");
assert_eq!(heading.font_size, Some(24.0));
}
#[test]
fn every_heading_level_is_a_different_size() {
let mut sizes = Vec::new();
for level in 1..=6 {
let src = format!("{} h", "#".repeat(level));
let r = render_markdown(&src, 16.0);
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
}
let mut sorted = sizes.clone();
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
sorted.dedup();
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
}
#[test]
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
assert!(r.text.contains("the docs"));
assert!(
!r.text.contains("example.com"),
"the URL should not leak into the visible text"
);
let link = r.spans.iter().find(|s| s.underline).unwrap();
assert_eq!(&r.text[link.range.clone()], "the docs");
let at = r.text.find("docs").unwrap();
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
let past = r.text.find("for").unwrap();
assert!(r.link_at(past).is_none());
}
#[test]
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
let r = block("```rust\nlet x = 1; // note\n```");
assert_eq!(r.text, "let x = 1; // note");
let keyword = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
.expect("a rust fence colours its keywords");
assert_eq!(&r.text[keyword.range.clone()], "let");
let comment = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
.unwrap();
assert_eq!(&r.text[comment.range.clone()], "// note");
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
}
#[test]
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
let r = block("```brainfuck\nlet x = 1;\n```");
assert_eq!(r.text, "let x = 1;");
assert_eq!(r.spans.len(), 1);
assert!(r.spans[0].family == Some(Family::Monospace));
assert_eq!(r.spans[0].color, Some(code_color()));
}
#[test]
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
for span in &r.spans {
assert!(
r.text.is_char_boundary(span.range.start)
&& r.text.is_char_boundary(span.range.end),
"span {:?} is not on a char boundary of {:?}",
span.range,
r.text
);
}
let string = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::String)))
.unwrap();
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
}
#[test]
fn an_unterminated_fence_still_renders_what_arrived() {
let r = block("```rust\nlet x = 1;");
assert_eq!(r.text, "let x = 1;");
assert!(
r.spans
.iter()
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
);
}
#[test]
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
let r = block("- one\n- two\n - deep");
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(marker_color()))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
}
#[test]
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
let r = block("3. three\n4. four");
assert_eq!(r.text, "3. three\n4. four");
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(marker_color()))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["3. ", "4. "]);
}
#[test]
fn a_quote_is_its_text_and_takes_the_quote_frame() {
let blocks = split_blocks("> quoted words\n> still quoted");
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
let r = render_block(&blocks[0], 16.0);
assert_eq!(r.text, "quoted words still quoted");
}
#[test]
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
use BlockKind::*;
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
assert_eq!(frame_of(Heading), BlockFrame::Plain);
assert_eq!(frame_of(List), BlockFrame::Plain);
assert_eq!(frame_of(Other), BlockFrame::Plain);
assert_eq!(frame_of(Quote), BlockFrame::Quote);
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
assert_ne!(
frame_of(Code),
frame_of(Table),
"a fence and a table sit on different fills"
);
}
#[test]
fn a_table_pads_its_columns_to_the_widest_cell() {
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
let lines: Vec<&str> = r.text.lines().collect();
assert_eq!(lines[0], "a bb");
assert_eq!(lines[1], "\u{2500}".repeat(8));
assert_eq!(lines[2], "cccc d");
let bold = r.spans.iter().find(|s| s.bold).unwrap();
assert_eq!(&r.text[bold.range.clone()], "a bb");
}
#[test]
fn a_long_table_cell_wraps_inside_its_column() {
let long = "one two three four five six seven eight nine ten eleven twelve";
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
for line in r.text.lines() {
assert!(
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
"line too wide: {line:?}"
);
}
assert!(r.text.contains("twelve"));
}
#[test]
fn a_task_list_marks_its_boxes() {
let r = block("- [x] done\n- [ ] not");
assert!(r.text.contains("[x] done"));
assert!(r.text.contains("[ ] not"));
}
}
+802
View File
@@ -0,0 +1,802 @@
pub mod composer;
// The checked-in bench fixture opened as a real screen -- 1.9 MB of
// `include_str!`, so it is a feature rather than always present: a build
// meant for a phone must not carry it. `bench` turns it on; so does the
// default, which is what makes `cargo test` here run the harness tests.
#[cfg(feature = "fixture")]
pub mod fixture;
pub mod markdown;
pub mod row;
pub(crate) mod tap;
pub mod theme;
pub mod tool;
use crate::client::transcript_fold::TranscriptRow as FoldedRow;
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
use theme::Theme;
pub struct TranscriptScreen {
/// The transcript's own `LazySpan` -- the layout *and* the scroll
/// position, since a lazy span owns a `ScrollController` of its own
/// rather than being wrapped in a `ScrollArea` (`docs/SCROLL.md`).
/// Exposed so a caller can read `.extent()`, drive it through
/// `Scrollable` (`.scroll()`, `.fling()`, `.amt()`) or call
/// `.jump_to_end()` directly.
pub list: WeakWidget<LazySpan>,
pub composer: composer::Composer,
rebuilds: std::cell::Cell<usize>,
tail: RefCell<Option<(RowKey, row::TailRow)>>,
session_working: std::cell::Cell<bool>,
theme: Rc<Theme>,
}
impl TranscriptScreen {
/// Append one more folded row at the live end of the transcript --
/// what a caller's SSE loop or a sent message calls as new events
/// arrive. `LazySpan::push_back` is O(1) and keeps the view pinned to the
/// newest content when it already was (I3).
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
where
Rsc::State: FocusHost + OpenUrl,
{
// Capped like any other row (`row::build_row`'s `cap`). A reply
// that goes on to *grow* past the cap is never capped, because it
// grows through `RowBlocks::apply_delta`, which appends to what is
// already drawn -- so the cap only ever catches a row that arrived
// long, which is the one nobody is watching arrive.
let (key, widget, tail) = row::build_row(
rsc,
self.list,
row,
self.session_working.get(),
true,
self.theme.clone(),
);
(self.list)(rsc).push_back(LazyItem::new(key, widget));
*self.tail.borrow_mut() = tail.map(|t| (key, t));
}
pub fn set_session_working<Rsc: HasEvents>(&self, rsc: &mut Rsc, working: bool)
where
Rsc::State: FocusHost + OpenUrl,
{
if self.session_working.replace(working) == working {
return;
}
let mut tail = self.tail.borrow_mut();
if let Some((_, row::TailRow::Tools(tools))) = tail.as_mut() {
let calls = tools.calls();
tools.apply_calls(rsc, &calls, working);
}
}
#[cfg(test)]
fn tail_card_count(&self) -> usize {
match self.tail.borrow().as_ref() {
Some((_, row::TailRow::Tools(tools))) => tools.card_count(),
_ => 0,
}
}
/// Open or close the newest row's tool run, when it is one -- what a
/// caller with no finger needs (`run-headless.sh`'s screenshot on this
/// displayless machine, and the tests below). Answers whether there
/// was such a row to act on, so a caller that expected one can say so
/// rather than silently producing the collapsed picture.
pub fn expand_tail_tools<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
let tail = self.tail.borrow();
let Some((_, row::TailRow::Tools(tools))) = tail.as_ref() else {
return false;
};
tools.set_group_expanded(rsc, expanded);
true
}
/// The `ReplaceLast` fast path: update the tail row in place if this
/// really is a change to the same row, and say whether that worked.
/// `false` for anything the caller must rebuild instead.
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
let mut tail = self.tail.borrow_mut();
let Some((tail_key, kept)) = tail.as_mut() else {
return false;
};
if *tail_key != key {
return false;
}
match (kept, row) {
(row::TailRow::Blocks(blocks), FoldedRow::Single(item)) => {
let (sender, markdown_src) = row::item_content(item);
// A tool call is drawn as a card, never as markdown, so a
// row that kept blocks and now holds one is a different
// row -- rebuild it.
if matches!(
item,
crate::client::transcript_fold::TranscriptItem::ToolRun { .. }
) {
return false;
}
blocks.apply_delta(rsc, sender, &markdown_src)
}
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
tools.apply_calls(rsc, calls, self.session_working.get())
}
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working.get())
}
(row::TailRow::Blocks(_), FoldedRow::Tools(_)) => false,
}
}
/// Anything else -- a row *before* the tail changed, which only
/// happens when `group_tool_runs` regroups already-seen items (a tool
/// run's calls that used to be separate rows join once the run closes)
/// -- falls back to a full rebuild: every row is dropped
/// (`LazySpan::clear`) and rebuilt from `new`. Counted in
/// [`Self::take_rebuilds`] so a caller (a report, a test) can see how
/// often the fallback actually fires rather than assuming it never
/// does.
pub fn apply<Rsc: HasEvents>(
&self,
rsc: &mut Rsc,
old: &[crate::client::transcript_fold::TranscriptItem],
new: &[crate::client::transcript_fold::TranscriptItem],
) where
Rsc::State: FocusHost + OpenUrl,
{
use crate::client::transcript_fold::group_tool_runs;
let old_rows = group_tool_runs(old);
let new_rows = group_tool_runs(new);
match diff_rows(&old_rows, &new_rows) {
RowDiff::Unchanged => {}
RowDiff::Appended { common } => {
for row in &new_rows[common..] {
self.push_row(rsc, row);
}
}
RowDiff::ReplaceLast { common } => {
let old_key = row::row_key(&old_rows[common].key());
let new_key = row::row_key(&new_rows[common].key());
if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) {
for row in &new_rows[common + 1..] {
self.push_row(rsc, row);
}
return;
}
let (new_key, widget, kept) = row::build_row(
rsc,
self.list,
&new_rows[common],
self.session_working.get(),
false,
self.theme.clone(),
);
let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget));
drop(evicted); // frees the old row's widget, same as a pop would
*self.tail.borrow_mut() = kept.map(|t| (new_key, t));
for row in &new_rows[common + 1..] {
self.push_row(rsc, row);
}
}
RowDiff::Rebuild => {
self.rebuilds.set(self.rebuilds.get() + 1);
(self.list)(rsc).clear();
*self.tail.borrow_mut() = None;
for row in &new_rows {
self.push_row(rsc, row);
}
}
}
}
pub fn take_rebuilds(&self) -> usize {
self.rebuilds.replace(0)
}
/// The semantic paint IDs used by this screen. A caller can replace
/// their entries through `rsc.ui_mut().paints.set(...)`; retained text
/// and rect primitives keep the IDs and need no widget rebuild.
pub fn theme(&self) -> &Theme {
&self.theme
}
/// The concatenated text of whatever is currently selected across one
/// or more rows, `None` if nothing is -- what a copy command reads.
pub fn selected_text<Rsc: HasEvents>(&self, rsc: &mut Rsc) -> Option<String> {
let id = rsc
.events()
.controllers
.id::<SelectionController>(self.list.id())?;
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
selection.selected_text(rsc)
})?
}
}
pub fn build<Rsc: HasEvents>(
rsc: &mut Rsc,
ui_state: &mut impl HasRoot<Rsc>,
rows: Vec<FoldedRow>,
) -> TranscriptScreen
where
Rsc::State: FocusHost + OpenUrl,
{
let (screen, tree) = build_tree(rsc, rows);
ui_state.set_root(rsc, tree);
screen
}
pub fn build_tree<Rsc: HasEvents>(
rsc: &mut Rsc,
rows: Vec<FoldedRow>,
) -> (TranscriptScreen, StrongWidget)
where
Rsc::State: FocusHost + OpenUrl,
{
let theme = Rc::new(Theme::new(&mut rsc.ui_mut().paints));
let list = LazySpan::new(Dir::DOWN, Pin::End).add(rsc);
list.controller(
SelectionController::new()
.with_scroll(list)
.separator("\n\n"),
)
.add(rsc);
// The last row's block widgets are kept for the same reason
// `push_row` keeps them: a reply that is *already* streaming when the
// screen is built takes its next delta through `apply`, and a `None`
// here would send that delta down the rebuild path instead -- the
// whole message re-shaped, which is exactly what the per-block column
// exists to avoid, and nothing on screen or in `take_rebuilds` would
// say so.
let mut tail = None;
for (i, row) in rows.iter().enumerate() {
// `false`: a row built here is history until the caller says the
// session is working (`TranscriptScreen::set_session_working`),
// and claiming a call is running because the screen happens to be
// opening is exactly the inferred-as-measured mistake.
// `cap`: every row but the last. The last is the tail, which may
// be a reply already streaming when this screen opened, and a
// capped row cannot take a delta (`RowBlocks::capped`).
let cap = i + 1 < rows.len();
let (key, widget, kept) = row::build_row(rsc, list, row, false, cap, theme.clone());
list(rsc).push_back(LazyItem::new(key, widget));
tail = kept.map(|t| (key, t));
}
// The controller host covers gaps as well as text, so a tap anywhere in
// the transcript can dismiss a selection. Text and link listeners may
// see the same physical sample; `SelectionController` deduplicates it by
// the sample's own timestamp while still returning the same tap decision
// to whichever leaf owns the link action.
{
list.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let input = &ctx.data;
rsc.with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
selection.drag(id, rsc, input)
});
})
.add(rsc);
}
list.on(CursorSense::Scroll(Axis::Y), |ctx, rsc| {
let delta = ctx.data.scroll_delta.y * 50.0;
ctx.widget(rsc).scroll(delta);
})
.add(rsc);
let (composer, composer_bar) = composer::build_composer(rsc, &theme);
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
.span(Dir::DOWN)
.add_strong(rsc)
.any();
(
TranscriptScreen {
tail: RefCell::new(tail),
session_working: std::cell::Cell::new(false),
list,
composer,
rebuilds: std::cell::Cell::new(0),
theme,
},
tree,
)
}
/// What changed at the tail between two folded row lists -- the decision
/// [`TranscriptScreen::apply`] acts on. Kept as its own pure function, no
/// widget and no `Rsc`, so the three cases can be tested directly against
/// synthetic `Vec<FoldedRow>`s (below) rather than needing a full widget
/// harness to exercise logic that never touches one.
#[derive(Debug, PartialEq, Eq)]
enum RowDiff {
Unchanged,
Appended { common: usize },
ReplaceLast { common: usize },
Rebuild,
}
fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff {
let common = old
.iter()
.zip(new.iter())
.take_while(|(a, b)| a == b)
.count();
if common == old.len() && common == new.len() {
RowDiff::Unchanged
} else if common == old.len() {
RowDiff::Appended { common }
} else if !old.is_empty() && common == old.len() - 1 && common < new.len() {
RowDiff::ReplaceLast { common }
} else {
RowDiff::Rebuild
}
}
#[cfg(test)]
mod diff_tests {
use super::*;
use crate::client::transcript_fold::TranscriptItem;
fn user(seq: u64, text: &str) -> FoldedRow {
FoldedRow::Single(TranscriptItem::UserMsg {
seq,
text: text.to_string(),
attachments: Vec::new(),
})
}
fn assistant(seq: u64, text: &str, settled: bool) -> FoldedRow {
FoldedRow::Single(TranscriptItem::AssistantMsg {
seq,
text: text.to_string(),
settled,
})
}
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
TranscriptItem::ToolRun {
seq,
id: format!("id{seq}"),
run_id: run_id.to_string(),
tool: "grep".to_string(),
input: "x".to_string(),
output: String::new(),
done: false,
failed: false,
asks: Vec::new(),
images: Vec::new(),
}
}
#[test]
fn identical_lists_are_unchanged() {
let rows = vec![user(1, "hi"), assistant(2, "hello", true)];
assert_eq!(diff_rows(&rows, &rows.clone()), RowDiff::Unchanged);
}
#[test]
fn an_empty_list_growing_by_one_is_an_append_from_zero() {
let old: Vec<FoldedRow> = Vec::new();
let new = vec![user(1, "hi")];
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 0 });
}
#[test]
fn a_new_message_after_a_settled_reply_is_a_pure_append() {
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 2 });
}
#[test]
fn a_delta_into_the_open_reply_is_a_last_row_replace() {
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
let new = vec![user(1, "hi"), assistant(2, "hello", false)];
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
}
#[test]
fn a_delta_that_both_settles_the_reply_and_starts_the_next_row_is_still_a_replace() {
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
}
#[test]
fn a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback() {
let old = vec![FoldedRow::Single(tool(1, "run-a")), user(2, "meanwhile")];
let new = vec![FoldedRow::Tools(vec![tool(1, "run-a"), tool(3, "run-a")])];
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
}
#[test]
fn shrinking_the_list_is_a_rebuild() {
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
let new = vec![user(1, "hi")];
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
}
}
#[cfg(test)]
mod apply_tests {
use super::*;
use crate::client::transcript_fold::TranscriptItem;
struct TestFocus {
focus: Option<WeakWidget<TextEdit>>,
}
impl OpenUrl for TestFocus {
fn open_url(&mut self, _url: &str) {}
}
impl FocusHost for TestFocus {
fn recent_click(&mut self) -> bool {
false
}
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
self.focus = id;
}
fn focus_gained(&mut self, _region: Option<PixelRegion>) {}
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.focus == Some(id)
}
}
struct TestRsc {
ui: UiData,
events: EventManager<TestRsc>,
}
impl UiRsc for TestRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
fn on_draw(&mut self, active: &ActiveData) {
self.events.draw(active);
}
fn on_undraw(&mut self, active: &ActiveData) {
self.events.undraw(active);
}
fn on_remove(&mut self, id: WidgetId) {
self.events.remove(id);
}
}
impl HasState for TestRsc {
type State = TestFocus;
}
impl HasEvents for TestRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
fn user(seq: u64, text: &str) -> TranscriptItem {
TranscriptItem::UserMsg {
seq,
text: text.to_string(),
attachments: Vec::new(),
}
}
fn assistant(seq: u64, text: &str) -> TranscriptItem {
TranscriptItem::AssistantMsg {
seq,
text: text.to_string(),
settled: false,
}
}
fn reply(paragraphs: usize, tail: &str) -> String {
let mut out = String::new();
for i in 0..paragraphs {
out.push_str(&format!("Paragraph number {i} of a streamed reply.\n\n"));
}
out.push_str(tail);
out
}
fn cost_of_one_delta(paragraphs: usize) -> (u64, u64) {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let old_items = vec![assistant(1, &reply(paragraphs, "and the last one is st"))];
let new_items = vec![assistant(
1,
&reply(paragraphs, "and the last one is still going."),
)];
let (screen, tree) = build_tree(
&mut rsc,
crate::client::transcript_fold::group_tool_runs(&old_items),
);
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
render.take_counters();
screen.apply(&mut rsc, &old_items, &new_items);
render.update(&tree, &mut rsc);
assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken");
let (draws, _, _, shapes) = render.take_counters();
(draws, shapes)
}
#[test]
fn a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one() {
assert!(
reply(100, "").len() > 3_000,
"the long case must actually be a long message"
);
let (short_draws, short_shapes) = cost_of_one_delta(1);
let (long_draws, long_shapes) = cost_of_one_delta(100);
assert_eq!(
short_draws, long_draws,
"a delta into a 100-paragraph reply redrew {long_draws} widgets against \
{short_draws} for a one-paragraph reply -- the earlier blocks are not being kept"
);
assert_eq!(
(short_shapes, long_shapes),
(1, 1),
"a delta shaped {long_shapes} text layouts in a 100-paragraph reply and \
{short_shapes} in a one-paragraph one; it must be the last block and nothing else"
);
}
fn call(id: &str, output: &str, done: bool) -> TranscriptItem {
TranscriptItem::ToolRun {
seq: 1,
id: id.to_string(),
run_id: "run".to_string(),
tool: "Bash".to_string(),
input: format!(r#"{{"command":"grep -rn {id} ."}}"#),
output: output.to_string(),
done,
failed: false,
asks: Vec::new(),
images: Vec::new(),
}
}
fn run_of(count: usize, output: &str, done: bool) -> Vec<TranscriptItem> {
(0..count)
.map(|i| call(&format!("t{i}"), output, done))
.collect()
}
fn open_run(
rsc: &mut TestRsc,
items: &[TranscriptItem],
) -> (TranscriptScreen, StrongWidget, UiRenderState) {
let (screen, tree) =
build_tree(rsc, crate::client::transcript_fold::group_tool_runs(items));
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, rsc);
assert!(
screen.expand_tail_tools(rsc, true),
"the fixture's only row must be the tool run"
);
render.update(&tree, rsc);
render.take_counters();
(screen, tree, render)
}
fn shapes_to_open(output: &str) -> u64 {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let items = run_of(3, output, true);
let (screen, tree) = build_tree(
&mut rsc,
crate::client::transcript_fold::group_tool_runs(&items),
);
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
render.take_counters();
assert!(
screen.expand_tail_tools(&mut rsc, true),
"the fixture's only row must be the tool run"
);
render.update(&tree, &mut rsc);
let (_, _, _, shapes) = render.take_counters();
shapes
}
#[test]
fn collapsed_cards_shape_only_their_summary_lines() {
let long: String = std::iter::repeat_n("a line of tool output\n", 4_000).collect();
assert!(long.len() > 80_000, "the long case must actually be long");
let short_shapes = shapes_to_open("ok\n");
let long_shapes = shapes_to_open(&long);
assert!(
short_shapes > 0,
"opening a group must shape something, or this compares two zeroes"
);
assert_eq!(
short_shapes, long_shapes,
"three collapsed cards shaped {long_shapes} text layouts over 80 kB of output \
against {short_shapes} over three bytes -- a collapsed card is laying out \
something it does not draw"
);
}
fn shapes_for_message(text: &str) -> u64 {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let items = vec![
TranscriptItem::AssistantMsg {
seq: 1,
text: text.to_string(),
settled: true,
},
TranscriptItem::AssistantMsg {
seq: 2,
text: "ok".to_string(),
settled: true,
},
];
let (_screen, tree) = build_tree(
&mut rsc,
crate::client::transcript_fold::group_tool_runs(&items),
);
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
let (_, _, _, shapes) = render.take_counters();
shapes
}
#[test]
fn a_long_message_is_drawn_only_as_far_as_the_cap() {
let paragraphs = |n: usize| "a paragraph of a reply\n\n".repeat(n);
let capped = shapes_for_message(&paragraphs(crate::client::text_cap::MESSAGE_LINES * 4));
let bigger = shapes_for_message(&paragraphs(crate::client::text_cap::MESSAGE_LINES * 40));
assert!(
capped > 0,
"the screen shaped nothing, so this compares zeroes"
);
assert_eq!(
capped, bigger,
"a message ten times longer cost {bigger} text layouts against {capped} -- the cap \
is not bounding what gets laid out",
);
}
fn cost_of_one_result(count: usize) -> u64 {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = run_of(count, "", false);
let mut after = before.clone();
after[0] = call("t0", "the result", true);
let (screen, tree, mut render) = open_run(&mut rsc, &before);
screen.apply(&mut rsc, &before, &after);
render.update(&tree, &mut rsc);
assert_eq!(
screen.take_rebuilds(),
0,
"a result arriving must not rebuild the whole screen"
);
let (draws, _, _, _) = render.take_counters();
draws
}
#[test]
fn a_result_arriving_redraws_one_card_whatever_the_run_holds() {
let small = cost_of_one_result(3);
let large = cost_of_one_result(12);
assert!(
small > 0,
"a result must redraw *something*, or this compares two zeroes"
);
assert_eq!(
small, large,
"one result redrew {large} widgets in a twelve-call run against {small} in a \
three-call one -- the other cards are being rebuilt with it"
);
}
#[test]
fn a_group_opens_and_closes_and_keeps_its_state_across_a_result() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = run_of(3, "", false);
let mut after = before.clone();
after[1] = call("t1", "done", true);
let (screen, tree) = build_tree(
&mut rsc,
crate::client::transcript_fold::group_tool_runs(&before),
);
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
assert_eq!(screen.tail_card_count(), 0);
assert!(screen.expand_tail_tools(&mut rsc, true));
assert_eq!(screen.tail_card_count(), 3);
screen.apply(&mut rsc, &before, &after);
render.update(&tree, &mut rsc);
assert_eq!(screen.take_rebuilds(), 0);
assert_eq!(
screen.tail_card_count(),
3,
"the group closed under a result"
);
assert!(screen.expand_tail_tools(&mut rsc, false));
assert_eq!(screen.tail_card_count(), 0);
}
#[test]
fn a_call_joining_an_open_run_appends_one_card() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = run_of(2, "ok", true);
let mut after = before.clone();
after.push(call("t2", "", false));
let (screen, tree, mut render) = open_run(&mut rsc, &before);
assert_eq!(screen.tail_card_count(), 2);
screen.apply(&mut rsc, &before, &after);
render.update(&tree, &mut rsc);
assert_eq!(
screen.take_rebuilds(),
0,
"an appended call is not a rebuild"
);
assert_eq!(screen.tail_card_count(), 3);
}
#[test]
fn a_tail_that_stops_being_tool_calls_falls_back_to_a_rebuild() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = vec![user(1, "stable"), call("t0", "", false)];
let after = vec![user(1, "stable"), user(2, "not a tool call at all")];
let (screen, _tree) = build_tree(
&mut rsc,
crate::client::transcript_fold::group_tool_runs(&before),
);
screen.apply(&mut rsc, &before, &after);
assert_eq!(
screen.take_rebuilds(),
0,
"this is a ReplaceLast, not a whole-screen rebuild"
);
assert_eq!(screen.tail_card_count(), 0);
}
}
+591
View File
@@ -0,0 +1,591 @@
use crate::client::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
use crate::client::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label};
use crate::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block};
use crate::ui::tap::{hold_edge, on_tap};
use crate::ui::theme::Theme;
use crate::ui::tool::ToolRow;
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
const BLOCK_GAP_DP: f32 = 8.0;
pub const BASE_SIZE: f32 = 16.0;
/// `ItemKey::Seq` already is the `RowKey` (`u64`) this crate's `LazySpan` wants.
/// `ItemKey::RunId` is a string (a tool call's own id), so it is hashed into
/// one -- collisions are not a correctness risk worth guarding against here
/// (a `DefaultHasher` collision across the run ids one session produces is
/// astronomically unlikely, and the consequence of one would only be two
/// tool-call rows sharing a list slot, not data loss), and the high bit is
/// forced on so a hashed key can never collide with a real sequence number
/// (this build never produces 2^63 events).
pub fn row_key(key: &crate::client::transcript_fold::ItemKey) -> RowKey {
use crate::client::transcript_fold::ItemKey;
use std::hash::{Hash, Hasher};
match key {
ItemKey::Seq(seq) => *seq,
ItemKey::RunId(id) => {
let mut h = std::collections::hash_map::DefaultHasher::new();
id.hash(&mut h);
h.finish() | (1 << 63)
}
}
}
/// The sender label shown above a row's text, and the markdown source to
/// render below it. `None` for a system-style note that has no sender.
pub(crate) fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
match item {
TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()),
TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()),
TranscriptItem::ErrorMsg { message, .. } => (Some("Error"), message.clone()),
TranscriptItem::CommandRow { text, .. } => (Some("Command"), format!("`/{text}`")),
TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()),
TranscriptItem::Note { text, .. } => (None, text.clone()),
TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()),
// Epoch seconds as-is until the port has a relative-time formatter
// (P1); the Compose `LimitRow` draws it as a countdown.
TranscriptItem::LimitNote { resets_at, .. } => (
None,
match resets_at {
Some(at) => format!("_Usage limit reached; resets at {at:.0} (epoch seconds)._"),
None => "_Usage limit reached._".to_string(),
},
),
TranscriptItem::CompactedNote {
pre_tokens,
post_tokens,
..
} => (
None,
match (pre_tokens, post_tokens) {
(Some(pre), Some(post)) => format!("_Compacted: {pre} -> {post} tokens._"),
_ => "_Compacted._".to_string(),
},
),
TranscriptItem::ImageItem { r#ref, .. } => (None, format!("_[image: {ref}]_")),
TranscriptItem::QuestionCard(card) => (Some("Question"), question_markdown(card)),
TranscriptItem::ToolRun {
tool,
input,
output,
..
} => (Some(tool.as_str()), tool_call_markdown(tool, input, output)),
}
}
fn question_markdown(card: &QuestionCard) -> String {
let mut out = card.prompt.clone();
for opt in &card.options {
out.push_str(&format!("\n- {}", opt.label));
}
out
}
fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
let mut out = format!("**{tool}**\n\n```\n{input}\n```");
if !output.is_empty() {
out.push_str(&format!("\n\n```\n{output}\n```"));
}
out
}
pub struct RowBlocks {
blocks: Vec<Block>,
fields: Vec<WeakWidget<Text>>,
links: Vec<Rc<RefCell<Vec<Link>>>>,
column: WeakWidget<Span>,
sender: Option<String>,
/// Whether this row draws less than the whole message
/// ([`cap_message`]). A delta cannot be appended to a capped row --
/// the new text would go on *below* the "Show all" that says it is
/// hidden -- so [`RowBlocks::apply_delta`] refuses one and the caller
/// rebuilds instead.
///
/// Never `true` for the row a reply is actually streaming into: the
/// live tail is built uncapped ([`build_row`]'s `cap`), which is what
/// keeps the refusal from costing anything in practice. This field is
/// the belt to that braces.
capped: bool,
theme: Rc<Theme>,
}
/// Split for display: never empty, so a row with nothing in it yet is
/// still one (empty) text widget rather than no widget at all -- an empty
/// column reports a zero size and the row would vanish from the list.
fn display_blocks(markdown_src: &str) -> Vec<Block> {
let blocks = split_blocks(markdown_src);
if blocks.is_empty() {
vec![Block {
kind: BlockKind::Paragraph,
source: markdown_src.to_string(),
}]
} else {
blocks
}
}
/// `blocks` cut to what a row draws, with the line count of the **whole**
/// message; `None` when all of it fits.
///
/// The cut prefers a **block boundary**, because a message is markdown and
/// a whole paragraph is a smaller version of a message in a way that half
/// a paragraph is not. Where one block is over the bound by itself -- the
/// reply that is one enormous fence -- that block is truncated instead of
/// being dropped or drawn whole: dropping it would leave a row saying
/// nothing, and a truncated fence still renders as a fence, since the
/// renderer already knows the block's kind and pulldown-cmark closes an
/// unterminated one at the end of its input.
fn cap_message(blocks: Vec<Block>, cap: bool) -> (Vec<Block>, Option<usize>) {
let total = || blocks.iter().map(|b| b.source.lines().count()).sum();
if !cap {
return (blocks, None);
}
let mut kept = Vec::with_capacity(blocks.len());
let (mut lines_left, mut bytes_left) = (MESSAGE_LINES, MESSAGE_BYTES);
for block in &blocks {
if lines_left == 0 || bytes_left == 0 {
return (kept, Some(total()));
}
match cut(&block.source, lines_left, bytes_left) {
Some((head, _)) if kept.is_empty() => {
kept.push(Block {
kind: block.kind,
source: head.to_string(),
});
return (kept, Some(total()));
}
Some(_) => return (kept, Some(total())),
None => {
lines_left -= block.source.lines().count().min(lines_left);
bytes_left -= block.source.len().min(bytes_left);
kept.push(block.clone());
}
}
}
(kept, None)
}
/// A message's own text, kept so that asking for the whole of a capped row
/// can rebuild it. `Rc` rather than a copy per closure: the source of a
/// long message is the largest string in the row, and the tap handler
/// would otherwise hold a second one for the lifetime of the row.
struct RowSource {
sender: Option<String>,
markdown: String,
}
const FRAME_PAD_DP: f32 = 10.0;
const QUOTE_BAR_DP: f32 = 3.0;
const FRAME_RADIUS_DP: f32 = 8.0;
fn build_block<Rsc: HasEvents>(
rsc: &mut Rsc,
block: &Block,
theme: &Theme,
) -> (WeakWidget<Text>, StrongWidget, Rc<RefCell<Vec<Link>>>)
where
Rsc::State: FocusHost + OpenUrl,
{
let frame = frame_of(block.kind, theme);
let rendered = render_block(block, BASE_SIZE, theme);
let links = Rc::new(RefCell::new(rendered.links));
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
let field = wtext(rendered.text)
.spans(rendered.spans)
.text_align(Align::LEFT)
.wrap(!verbatim)
.family(if verbatim {
Family::Monospace
} else {
Family::SansSerif
})
.size(BASE_SIZE)
.color(match frame {
BlockFrame::Quote => theme.quote_text.clone(),
_ => theme.text.clone(),
})
.add(rsc);
let tap_links = links.clone();
field
.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let (pos, size) = (ctx.data.pos, ctx.data.size);
let input = &ctx.data;
let outcome = rsc
.with_nearest_controller::<SelectionController, _>(field, |id, selection, rsc| {
selection.drag(id, rsc, input)
})
.unwrap_or(SelectionInput::Tapped);
// A *tap*, decided by the same `DragArbiter` the pan and
// the selection are: a gesture that panned the list past
// this link, or held long enough to select, must not also
// follow it (`GestureOutcome::Tapped`'s doc).
if outcome == SelectionInput::Tapped {
let byte = field.selection(rsc).byte_at(pos, size);
let url = tap_links
.borrow()
.iter()
.find(|l| l.range.contains(&byte))
.map(|l| l.url.clone());
if let Some(url) = url {
log::info!("iris link: opening {url}");
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
}
}
})
.add(rsc);
let framed = match frame {
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
BlockFrame::Verbatim { fill } => field
.scrollable(Axis::X, Pin::Start)
.pad(dp(FRAME_PAD_DP))
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any(),
BlockFrame::Quote => field
.width(rest(1))
.pad(Padding {
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
..Padding::ZERO
})
.background(rect(theme.quote_bar.clone()).width(dp(QUOTE_BAR_DP)))
.width(rest(1))
.add_strong(rsc)
.any(),
};
(field, framed, links)
}
#[allow(clippy::too_many_arguments)]
fn build_text_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
key: RowKey,
sender: Option<&str>,
markdown_src: &str,
cap: bool,
theme: Rc<Theme>,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost + OpenUrl,
{
let source = Rc::new(RowSource {
sender: sender.map(str::to_string),
markdown: markdown_src.to_string(),
});
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
let (content, blocks) = row_content(rsc, list, key, source, ptr, cap, theme);
ptr(rsc).set(content);
(strong.any(), blocks)
}
/// Separate from [`build_text_row`] because the tap calls it a second
/// time, with `cap` false, and writes the result back into the same
/// `WidgetPtr`.
#[allow(clippy::too_many_arguments)]
fn row_content<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
key: RowKey,
source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>,
cap: bool,
theme: Rc<Theme>,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost + OpenUrl,
{
let (blocks, hidden) = cap_message(display_blocks(&source.markdown), cap);
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
let mut fields = Vec::with_capacity(blocks.len());
let mut links = Vec::with_capacity(blocks.len());
for block in &blocks {
let (field, framed, block_links) = build_block(rsc, block, &theme);
fields.push(field);
links.push(block_links);
column.push(framed);
}
if let Some(lines) = hidden {
column.push(show_all(
rsc,
list,
key,
source.clone(),
ptr,
lines,
theme.clone(),
));
}
let column = column.add(rsc);
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded
// as a child of the `.span(Dir::DOWN)` below, whose own composition is
// what performs the *one* real strong registration each child gets.
// Calling `.add_strong`/`.upgrade` here too, then feeding a `.weak()`
// copy into that composition, tried to strong-register the same id
// twice and panicked with "was already added"
// (`core/src/widget/like.rs:12`) -- found running this crate's own
// `run-headless.sh` example, the first real render of a row.
let header: WeakWidget = match &source.sender {
Some(name) => wtext(name.clone())
.size(13.0)
.color(theme.secondary_text.clone())
.add(rsc),
None => Span::empty(Dir::DOWN).add(rsc),
};
let widget = (header, column.width(rest(1)))
.span(Dir::DOWN)
.gap(dp(4))
.pad(dp(10))
.add_strong(rsc)
.any();
(
widget,
RowBlocks {
blocks,
fields,
links,
column,
sender: source.sender.clone(),
capped: hidden.is_some(),
theme,
},
)
}
/// The `RowBlocks` the rebuild produces is **discarded**, because a capped
/// row is never the row a reply is streaming into (`build_row`'s `cap`) --
/// so nothing is holding one for it, and there is nothing to keep in step.
#[allow(clippy::too_many_arguments)]
fn show_all<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
key: RowKey,
source: Rc<RowSource>,
ptr: WeakWidget<WidgetPtr>,
lines: usize,
theme: Rc<Theme>,
) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let label = show_all_label(lines);
let more_strong = WidgetPtr::new().add_strong(rsc);
let more = more_strong.weak();
let words = wtext(label.clone())
.size(13.0)
.color(theme.secondary_text.clone())
.text_align(Align::LEFT)
.label(label)
.add_strong(rsc);
more(rsc).set(words);
on_tap(rsc, more, list, move |rsc| {
hold_edge(rsc, list, key);
let (content, _blocks) =
row_content(rsc, list, key, source.clone(), ptr, false, theme.clone());
let _old = ptr(rsc).replace(content);
});
more_strong.any()
}
impl RowBlocks {
/// Bring this row up to date with `markdown_src` **without** re-laying
/// out the blocks that did not change, and say whether that was
/// possible. `false` means the caller must rebuild the row the
/// ordinary way: an earlier block was rewritten (markdown allows it --
/// a trailing `---` turns the paragraph above into a heading), the
/// sender changed, or the message got shorter.
pub fn apply_delta<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
sender: Option<&str>,
markdown_src: &str,
) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
if self.sender.as_deref() != sender {
return false;
}
// A capped row draws less than the message it was built from, so
// appending to it would put the new text *below* the "Show all"
// saying the rest is hidden. The caller rebuilds instead, and
// rebuilds uncapped (`TranscriptScreen::apply`), so this refusal
// costs one rebuild per message rather than one per delta.
if self.capped {
return false;
}
let new_blocks = display_blocks(markdown_src);
let common = common_prefix(&self.blocks, &new_blocks);
// Everything already drawn must either be kept whole (`common ==
// len`, a pure append) or be kept except for the last block, which
// is the one a delta lands in. Anything else means an already
// laid-out block is no longer what it was.
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
return false;
}
if new_blocks.len() == self.blocks.len()
&& common < self.blocks.len()
&& new_blocks[common].kind != self.blocks[common].kind
{
return false;
}
debug_assert!(
self.fields.len() == self.blocks.len() && self.links.len() == self.blocks.len(),
"one field and one link list per block: {} fields, {} links, {} blocks",
self.fields.len(),
self.links.len(),
self.blocks.len()
);
for (i, block) in new_blocks.iter().enumerate().skip(common) {
match (self.fields.get(i), self.links.get(i)) {
(Some(field), Some(links)) => {
let rendered = render_block(block, BASE_SIZE, &self.theme);
field(rsc).set_with_spans(rendered.text, rendered.spans);
*links.borrow_mut() = rendered.links;
}
_ => {
let (field, framed, links) = build_block(rsc, block, &self.theme);
self.fields.push(field);
self.links.push(links);
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
column.push(framed);
}
}
}
}
self.blocks = new_blocks;
true
}
}
fn build_single<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
key: RowKey,
item: &TranscriptItem,
cap: bool,
theme: Rc<Theme>,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost + OpenUrl,
{
let (sender, markdown_src) = item_content(item);
build_text_row(rsc, list, key, sender, &markdown_src, cap, theme)
}
/// Two mechanisms would have been two answers to the same question ("what
/// can this row do cheaply?"), so the caller holds one of these for its
/// tail row and asks it, rather than holding a `RowBlocks` and a
/// `ToolRow` and choosing between them at each call site.
pub enum TailRow {
Blocks(RowBlocks),
Tools(ToolRow),
}
/// `cap` draws a long message as [`cap_message`]'s worth of it behind a
/// "Show all"; the caller passes `false` for the **live tail**, the row a
/// reply is streaming into, because a row that grows while it is capped
/// would appear to stop growing (`RowBlocks::capped`). Every other row is
/// capped.
pub fn build_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
row: &FoldedRow,
working: bool,
cap: bool,
theme: Rc<Theme>,
) -> (RowKey, StrongWidget, Option<TailRow>)
where
Rsc::State: FocusHost + OpenUrl,
{
// A lone tool call is a card too, not a message with markdown in it:
// `group_tool_runs` leaves one call as a `Single` because "Called 1
// tool" hides a card to say the same thing in more words, and the
// *card* is what both cases draw (`ToolRows.kt`).
let calls = match row {
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => {
Some(std::slice::from_ref(item))
}
FoldedRow::Tools(calls) => Some(calls.as_slice()),
FoldedRow::Single(_) => None,
};
if let Some(calls) = calls {
let key = row_key(&calls[0].key());
let (widget, tools) =
crate::ui::tool::build_tool_row(rsc, list, key, calls.to_vec(), working, theme);
return (key, widget, Some(TailRow::Tools(tools)));
}
let FoldedRow::Single(item) = row else {
unreachable!("every Tools row took the branch above");
};
let key = row_key(&item.key());
let (widget, blocks) = build_single(rsc, list, key, item, cap, theme);
(key, widget, Some(TailRow::Blocks(blocks)))
}
#[cfg(test)]
mod tests {
use super::*;
fn blocks(src: &str) -> Vec<Block> {
display_blocks(src)
}
#[test]
fn a_message_inside_the_bounds_is_not_capped() {
let (kept, hidden) = cap_message(blocks("hello\n\nthere"), true);
assert_eq!(kept.len(), 2);
assert_eq!(hidden, None);
}
#[test]
fn cap_false_keeps_everything() {
let src = "a\n\n".repeat(MESSAGE_LINES * 2);
let (kept, hidden) = cap_message(blocks(&src), false);
assert_eq!(kept.len(), MESSAGE_LINES * 2);
assert_eq!(hidden, None);
}
#[test]
fn a_long_message_is_cut_on_a_block_boundary() {
let src = "a paragraph\n\n".repeat(MESSAGE_LINES * 2);
let all = blocks(&src);
let (kept, hidden) = cap_message(all.clone(), true);
assert!(kept.len() < all.len(), "nothing was left out");
assert!(
kept.iter().zip(&all).all(|(k, a)| k == a),
"a block was truncated where a boundary was available",
);
assert_eq!(
hidden,
Some(all.iter().map(|b| b.source.lines().count()).sum()),
"the offer says the whole message's line count, not the shown part's",
);
}
#[test]
fn one_block_over_the_bound_by_itself_is_truncated() {
let src = format!("```\n{}```", "x\n".repeat(MESSAGE_LINES * 2));
let all = blocks(&src);
assert_eq!(all.len(), 1, "the fixture must be a single block");
let (kept, hidden) = cap_message(all.clone(), true);
assert_eq!(kept.len(), 1);
assert_eq!(
kept[0].kind, all[0].kind,
"truncation changed the block's kind"
);
assert!(
kept[0].source.len() < all[0].source.len(),
"the one over-long block was drawn whole",
);
assert!(hidden.is_some());
}
}
+29
View File
@@ -0,0 +1,29 @@
use iris::prelude::*;
pub(crate) fn on_tap<Rsc: HasEvents>(
rsc: &mut Rsc,
ptr: WeakWidget<WidgetPtr>,
list: WeakWidget<LazySpan>,
f: impl Fn(&mut Rsc) + 'static,
) where
Rsc::State: FocusHost + OpenUrl,
{
ptr.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let input = &ctx.data;
let outcome = rsc
.with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
selection.drag(id, rsc, input)
})
.unwrap_or(SelectionInput::Tapped);
if outcome == SelectionInput::Tapped {
f(rsc);
}
})
.add(rsc);
}
pub(crate) fn hold_edge(rsc: &mut impl UiRsc, list: WeakWidget<LazySpan>, key: RowKey) {
if let Some((top, _bottom)) = list(rsc).extent(key) {
list(rsc).note_tap(top);
}
}
+70
View File
@@ -0,0 +1,70 @@
use iris::prelude::*;
/// The shared phone/desktop paint handles. Replacing their paint-table
/// entries changes the theme without rebuilding widgets or primitives.
#[derive(Clone)]
pub struct Theme {
pub text: PaintId,
pub code: PaintId,
pub link: PaintId,
pub marker: PaintId,
pub verbatim_surface: PaintId,
pub table_surface: PaintId,
pub quote_bar: PaintId,
pub quote_text: PaintId,
pub strikethrough: PaintId,
pub card_surface: PaintId,
pub group_surface: PaintId,
pub muted: PaintId,
pub awaiting: PaintId,
pub failed: PaintId,
pub unknown: PaintId,
pub composer_surface: PaintId,
pub secondary_text: PaintId,
pub syntax_keyword: PaintId,
pub syntax_string: PaintId,
pub syntax_literal: PaintId,
pub syntax_comment: PaintId,
pub syntax_metadata: PaintId,
pub syntax_punctuation: PaintId,
pub syntax_mark: PaintId,
}
impl Theme {
pub fn new(paints: &mut Paints) -> Self {
Self {
text: paints.add(srgb(0xCDD6F4)),
code: paints.add(srgb(0xCDD6F4)),
link: paints.add(srgb(0x89B4FA)),
marker: paints.add(srgb(0xB4BEFE)),
verbatim_surface: paints.add(srgb(0x11111B)),
table_surface: paints.add(srgb(0x313244)),
quote_bar: paints.add(srgb(0x585B70)),
quote_text: paints.add(srgb(0xA6ADC8)),
strikethrough: paints.add(srgb(0x6C7086)),
card_surface: paints.add(srgb(0x313244)),
group_surface: paints.add(srgb(0x181825)),
muted: paints.add(srgb(0xA6ADC8)),
awaiting: paints.add(srgb(0xFAB387)),
failed: paints.add(srgb(0xF38BA8)),
unknown: paints.add(srgb(0xF9E2AF)),
composer_surface: paints.add(Srgba8::rgb(40, 40, 46)),
secondary_text: paints.add(Srgba8::rgb(150, 150, 160)),
syntax_keyword: paints.add(srgb(0xCBA6F7)),
syntax_string: paints.add(srgb(0xA6E3A1)),
syntax_literal: paints.add(srgb(0xFAB387)),
syntax_comment: paints.add(srgb(0x6C7086)),
syntax_metadata: paints.add(srgb(0xF9E2AF)),
syntax_punctuation: paints.add(srgb(0xA6ADC8)),
syntax_mark: paints.add(srgb(0x89DCEB)),
}
}
}
const fn srgb(hex: u32) -> Srgba8 {
Srgba8::rgb(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
)
}
+629
View File
@@ -0,0 +1,629 @@
use crate::client::text_cap::{VERBATIM_BYTES, VERBATIM_LINES, cut, show_all_label};
use crate::client::tool_summary::{ToolInput, parse_tool_input};
use crate::client::transcript_fold::{ToolState, TranscriptItem};
use crate::ui::markdown::highlight_into;
use crate::ui::tap::{hold_edge, on_tap};
use crate::ui::theme::Theme;
use iris::prelude::*;
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
const NAME_SIZE: f32 = 14.0;
const BODY_SIZE: f32 = 12.0;
const LABEL_SIZE: f32 = 11.0;
const CARD_PAD_DP: f32 = 12.0;
const CARD_RADIUS_DP: f32 = 12.0;
const GAP_DP: f32 = 8.0;
const RAW_RADIUS_DP: f32 = 4.0;
const RAW_PAD_DP: f32 = 8.0;
const GROUP_INSET_DP: f32 = 4.0;
const MARK_DP: f32 = 9.0;
#[derive(Default)]
struct ToolRowState {
group_expanded: bool,
open: HashMap<String, bool>,
whole: HashMap<(String, Part), bool>,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum Part {
Input,
Output,
}
struct Shared {
calls: RefCell<Vec<TranscriptItem>>,
state: RefCell<ToolRowState>,
/// One `WidgetPtr` per call, in order -- what makes a result cost one
/// card. Empty while the group is collapsed, because a collapsed group
/// draws no cards at all. Its path out is [`build_content`], which
/// clears it before building whatever replaces them.
cards: RefCell<Vec<WeakWidget<WidgetPtr>>>,
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
list: WeakWidget<LazySpan>,
key: RowKey,
working: Cell<bool>,
theme: Rc<Theme>,
}
/// One transcript row's worth of tool calls, kept by the caller for the
/// row a result can still land in -- the tool-call counterpart of
/// [`crate::ui::row::RowBlocks`], and the reason a `ToolEnd` costs one card
/// rather than a row.
pub struct ToolRow {
shared: Rc<Shared>,
}
fn text<Rsc>(content: impl Into<String>, size: f32, color: PaintId) -> TextBuilder<Rsc> {
wtext(content)
.size(size)
.color(color)
.text_align(Align::LEFT)
}
fn disclosure<Rsc>(glyph: &'static str, theme: &Theme) -> TextBuilder<Rsc> {
text(glyph, MARK_DP, theme.muted.clone()).family(Family::Icons)
}
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>, theme: &Theme) -> StrongWidget
where
Rsc::State: FocusHost,
{
let field = body
.family(Family::Monospace)
.size(BODY_SIZE)
.wrap(false)
.add(rsc);
field
.scrollable(Axis::X, Pin::Start)
.pad(dp(RAW_PAD_DP))
.masked_by(rect(theme.verbatim_surface.clone()).radius(dp(RAW_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any()
}
fn state_word(state: ToolState) -> Option<&'static str> {
match state {
ToolState::Deciding => Some("your turn"),
ToolState::Running => Some("running"),
ToolState::Failed => Some("failed"),
ToolState::NoResult => Some("no result"),
ToolState::Succeeded => None,
}
}
fn state_mark(state: ToolState, theme: &Theme) -> Option<(&'static str, PaintId)> {
let color = match state {
ToolState::Deciding => theme.awaiting.clone(),
ToolState::Running => theme.muted.clone(),
ToolState::Failed => theme.failed.clone(),
ToolState::NoResult => theme.unknown.clone(),
ToolState::Succeeded => return None,
};
Some((
state_word(state).expect("non-success state has a label"),
color,
))
}
/// What a screen reader is given for one card, and what a `ui-trace`
/// script taps by: the tool, what the call is for, and how it went when
/// that is anything but "fine" -- the same three things the Compose card's
/// own text says, in the order it says them.
fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String {
let mut name = tool.to_string();
if let Some(title) = parsed.title() {
name.push_str(": ");
name.push_str(title);
}
if let Some(word) = state_word(state) {
name.push_str(" (");
name.push_str(word);
name.push(')');
}
name
}
/// The heading a group carries, closed or open. Compose's exact wording,
/// because it is also the name every `ui-trace` script taps it by.
fn group_label(count: usize) -> String {
format!("Called {count} tools")
}
fn capped(body: &str, whole: bool) -> (&str, usize, bool) {
match cut(body, VERBATIM_LINES, VERBATIM_BYTES) {
Some((head, lines)) if !whole => (head, lines, true),
Some((_, lines)) => (body, lines, false),
None => (body, body.lines().count(), false),
}
}
fn wants_whole(shared: &Shared, id: &str, part: Part) -> bool {
shared
.state
.borrow()
.whole
.get(&(id.to_string(), part))
.copied()
.unwrap_or(false)
}
/// A control rather than a note, and it says the count rather than "more",
/// because the reader is deciding whether to ask for it: "Show all 4,000
/// lines" and "Show all 12 lines" are different decisions and the word
/// "more" tells them apart not at all.
fn show_all<Rsc: HasEvents>(
rsc: &mut Rsc,
shared: &Rc<Shared>,
index: usize,
id: &str,
part: Part,
lines: usize,
) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let label = show_all_label(lines);
let more_strong = WidgetPtr::new().add_strong(rsc);
let more = more_strong.weak();
let words = text(label.clone(), LABEL_SIZE, shared.theme.muted.clone())
.label(label)
.add_strong(rsc);
more(rsc).set(words);
let shared_for_tap = shared.clone();
let key = (id.to_string(), part);
on_tap(rsc, more, shared.list, move |rsc| {
hold_edge(rsc, shared_for_tap.list, shared_for_tap.key);
shared_for_tap
.state
.borrow_mut()
.whole
.insert(key.clone(), true);
redraw_card(rsc, &shared_for_tap, index);
});
more_strong.any()
}
fn output_block<Rsc: HasEvents>(
rsc: &mut Rsc,
shared: &Rc<Shared>,
index: usize,
id: &str,
output: &str,
call_state: ToolState,
) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
if output.is_empty() {
let (words, colour) = match call_state {
ToolState::Succeeded => ("No output", shared.theme.muted.clone()),
ToolState::Failed => ("Failed, with no output", shared.theme.failed.clone()),
ToolState::NoResult => ("No result ever arrived", shared.theme.unknown.clone()),
ToolState::Running | ToolState::Deciding => {
("No output yet", shared.theme.muted.clone())
}
};
return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
}
let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output));
let mut column = Span::empty(Dir::DOWN).gap(dp(2));
column.push(
text("Output", LABEL_SIZE, shared.theme.text.clone())
.add_strong(rsc)
.any(),
);
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone());
column.push(raw_block(rsc, body, &shared.theme));
if was_cut {
column.push(show_all(rsc, shared, index, id, Part::Output, lines));
}
column.width(rest(1)).add_strong(rsc).any()
}
fn build_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let call = shared.calls.borrow()[index].clone();
let TranscriptItem::ToolRun {
id,
tool,
input,
output,
..
} = &call
else {
debug_assert!(false, "a tool row holds only tool calls, not {call:?}");
return Span::empty(Dir::DOWN).add_strong(rsc).any();
};
let parsed = parse_tool_input(tool, input);
let call_state = ToolState::of(&call, shared.working.get()).expect("matched ToolRun above");
let open = shared.state.borrow().open.get(id).copied().unwrap_or(false)
|| call_state == ToolState::Deciding;
let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP));
header.push(
disclosure(if open { icon::OPEN } else { icon::CLOSED }, &shared.theme)
.add_strong(rsc)
.any(),
);
header.push(
text(tool.clone(), NAME_SIZE, shared.theme.text.clone())
.add_strong(rsc)
.any(),
);
match (open, parsed.title()) {
(true, _) | (false, None) => {
header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any())
}
(false, Some(title)) => header.push(
text(title.to_string(), BODY_SIZE, shared.theme.muted.clone())
.wrap(false)
.masked()
.width(rest(1))
.add_strong(rsc)
.any(),
),
}
if open && let Some(timeout) = &parsed.timeout {
header.push(
text(
format!("timeout {timeout}"),
LABEL_SIZE,
shared.theme.muted.clone(),
)
.add_strong(rsc)
.any(),
);
}
if let Some((word, colour)) = state_mark(call_state, &shared.theme) {
header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any());
}
let mut column = Span::empty(Dir::DOWN).gap(dp(GAP_DP / 2.0));
column.push(header.width(rest(1)).add_strong(rsc).any());
if open {
if let Some(description) = &parsed.description {
column.push(
text(description.clone(), BODY_SIZE, shared.theme.muted.clone())
.width(rest(1))
.add_strong(rsc)
.any(),
);
}
let whole = wants_whole(shared, id, Part::Input);
let mut input_lines = 0usize;
let mut input_cut = false;
if let Some(subject) = &parsed.subject {
let (shown, lines, was_cut) = capped(subject, whole);
input_lines += lines;
input_cut |= was_cut;
let spans = match parsed.language {
Some(language) => {
let mut spans = Vec::new();
highlight_into(&mut spans, shown, 0..shown.len(), language, &shared.theme);
spans
}
None => Vec::new(),
};
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone()).spans(spans);
column.push(raw_block(rsc, body, &shared.theme));
}
if !parsed.rest.is_empty() {
// Never dropped: a field left out would be claiming the tool
// had no other input when it might (`ToolInput.kt`). Capped is
// not dropped -- the field is still there, with its size said
// out loud.
let joined = parsed.rest.join("\n");
let (shown, lines, was_cut) = capped(&joined, whole);
input_lines += lines;
input_cut |= was_cut;
let body = text(shown.to_string(), BODY_SIZE, shared.theme.muted.clone());
column.push(raw_block(rsc, body, &shared.theme));
}
if input_cut {
column.push(show_all(rsc, shared, index, id, Part::Input, input_lines));
}
column.push(output_block(rsc, shared, index, id, output, call_state));
}
column
.width(rest(1))
.pad(dp(CARD_PAD_DP))
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.label(card_label(tool, &parsed, call_state))
.add_strong(rsc)
.any()
}
fn redraw_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize)
where
Rsc::State: FocusHost + OpenUrl,
{
let Some(ptr) = shared.card_ptr(index) else {
debug_assert!(false, "card {index} has no widget to redraw");
return;
};
let content = build_card(rsc, shared, index);
let _old = ptr(rsc).replace(content);
}
fn build_card_ptr<Rsc: HasEvents>(
rsc: &mut Rsc,
shared: &Rc<Shared>,
index: usize,
) -> (StrongWidget, WeakWidget<WidgetPtr>)
where
Rsc::State: FocusHost + OpenUrl,
{
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
shared.cards.borrow_mut().push(ptr);
debug_assert_eq!(
shared.cards.borrow().len(),
index + 1,
"a card's index is its position, and both are the call's"
);
let content = build_card(rsc, shared, index);
ptr(rsc).set(content);
let for_tap = shared.clone();
on_tap(rsc, ptr, shared.list, move |rsc| {
hold_edge(rsc, for_tap.list, for_tap.key);
let Some(id) = for_tap.call_id(index) else {
debug_assert!(false, "tapped card {index} is no longer in the row");
return;
};
let was = for_tap
.state
.borrow()
.open
.get(&id)
.copied()
.unwrap_or(false);
for_tap.state.borrow_mut().open.insert(id, !was);
redraw_card(rsc, &for_tap, index);
});
(strong.any(), ptr)
}
fn collapse_bar<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
let mark = disclosure(icon::COLLAPSE, &shared.theme)
.center_text()
.width(rest(1))
.pad(dp(CARD_PAD_DP))
// Anything shown only as a mark still needs a name: this is what
// a screen reader reads and what a `ui-trace` script taps.
.label("Collapse these tool calls")
.add_strong(rsc);
ptr(rsc).set(mark);
let for_tap = shared.clone();
on_tap(rsc, ptr, shared.list, move |rsc| {
toggle_group(rsc, &for_tap)
});
strong.any()
}
/// Rebuilt whole when the group opens or closes, because that is a change
/// of what the row *is* rather than of one card in it. Everything a single
/// card's tap does goes through [`redraw_card`] instead.
fn build_content<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
shared.cards.borrow_mut().clear();
let count = shared.calls.borrow().len();
debug_assert!(count > 0, "a tool row with no calls has nothing to draw");
if count == 1 {
return build_card_ptr(rsc, shared, 0).0;
}
if !shared.state.borrow().group_expanded {
let heading = group_label(count);
return text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
.pad(dp(CARD_PAD_DP))
.width(rest(1))
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.label(heading)
.add_strong(rsc)
.any();
}
let heading = group_label(count);
let mut group = Span::empty(Dir::DOWN);
group.push(
text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
.pad(dp(CARD_PAD_DP))
.width(rest(1))
.label(heading)
.add_strong(rsc)
.any(),
);
{
let mut cards = Span::empty(Dir::DOWN);
for index in 0..count {
cards.push(build_card_ptr(rsc, shared, index).0);
}
group.push(cards.pad(dp(GROUP_INSET_DP)).add_strong(rsc).any());
}
group.push(collapse_bar(rsc, shared));
group
.width(rest(1))
.background(rect(shared.theme.group_surface.clone()).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any()
}
fn toggle_group<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>)
where
Rsc::State: FocusHost + OpenUrl,
{
hold_edge(rsc, shared.list, shared.key);
let was = shared.state.borrow().group_expanded;
shared.state.borrow_mut().group_expanded = !was;
let content = build_content(rsc, shared);
shared.set_content(rsc, content);
}
impl Shared {
fn set_content(&self, rsc: &mut impl UiRsc, content: StrongWidget) {
let Some(ptr) = *self.content.borrow() else {
debug_assert!(
false,
"the row's content pointer is set before anything can tap it"
);
return;
};
let _old = ptr(rsc).replace(content);
}
fn call_id(&self, index: usize) -> Option<String> {
match self.calls.borrow().get(index) {
Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()),
_ => None,
}
}
fn card_ptr(&self, index: usize) -> Option<WeakWidget<WidgetPtr>> {
self.cards.borrow().get(index).copied()
}
}
/// `working` is the caller's `session_working` **for this row** -- true
/// only for the newest row of a session that is still doing something.
/// Every row behind it belongs to a turn that has ended, so a call in one
/// with no result never came back rather than still running.
pub fn build_tool_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
key: RowKey,
calls: Vec<TranscriptItem>,
working: bool,
theme: Rc<Theme>,
) -> (StrongWidget, ToolRow)
where
Rsc::State: FocusHost + OpenUrl,
{
let shared = Rc::new(Shared {
calls: RefCell::new(calls),
state: RefCell::new(ToolRowState::default()),
cards: RefCell::new(Vec::new()),
content: RefCell::new(None),
list,
key,
working: Cell::new(working),
theme,
});
let content_strong = WidgetPtr::new().add_strong(rsc);
let content = content_strong.weak();
*shared.content.borrow_mut() = Some(content);
let inner = build_content(rsc, &shared);
content(rsc).set(inner);
(content_strong.any(), ToolRow { shared })
}
impl ToolRow {
/// The calls this row is currently drawing -- what a caller passes
/// back to [`Self::apply_calls`] when something other than the calls
/// themselves changed (the session's status).
pub fn calls(&self) -> Vec<TranscriptItem> {
self.shared.calls.borrow().clone()
}
#[cfg(test)]
pub(crate) fn card_count(&self) -> usize {
self.shared.cards.borrow().len()
}
/// Exists because the expanded appearance is otherwise unreachable
/// from anything that cannot press the screen -- a headless
/// screenshot on this displayless machine, and a test. Same path a tap
/// takes, including `LazySpan::note_tap`, so what it produces is what a
/// reader would have got.
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
where
Rsc::State: FocusHost + OpenUrl,
{
if self.shared.state.borrow().group_expanded != expanded {
toggle_group(rsc, &self.shared);
}
}
/// Bring this row up to date with `calls` **without** rebuilding the
/// cards that did not change, and say whether that was possible.
/// `false` means the caller must rebuild the row the ordinary way.
pub fn apply_calls<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
calls: &[TranscriptItem],
working: bool,
) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
if calls.is_empty()
|| !calls
.iter()
.all(|c| matches!(c, TranscriptItem::ToolRun { .. }))
{
return false;
}
let old = self.shared.calls.borrow().clone();
if calls.len() < old.len() {
return false;
}
if (old.len() == 1) != (calls.len() == 1) {
return false;
}
let changed: Vec<usize> = (0..old.len()).filter(|&i| old[i] != calls[i]).collect();
self.shared.working.set(working);
*self.shared.calls.borrow_mut() = calls.to_vec();
let ids: std::collections::HashSet<String> = calls
.iter()
.filter_map(|c| match c {
TranscriptItem::ToolRun { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
{
let mut state = self.shared.state.borrow_mut();
state.open.retain(|id, _| ids.contains(id));
state.whole.retain(|(id, _), _| ids.contains(id));
}
if self.shared.cards.borrow().is_empty() {
if calls.len() != old.len() {
let content = build_content(rsc, &self.shared);
self.shared.set_content(rsc, content);
}
return true;
}
debug_assert_eq!(
self.shared.cards.borrow().len(),
old.len(),
"an open row draws exactly one card per call"
);
for index in changed {
redraw_card(rsc, &self.shared, index);
}
if calls.len() > old.len() {
let content = build_content(rsc, &self.shared);
self.shared.set_content(rsc, content);
}
true
}
}
+166
View File
@@ -0,0 +1,166 @@
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::{Harness, TouchAction, TouchScript};
use iris::prelude::*;
use iris::sense::DRAG_SLOP;
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
fn tracked_row(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> (RowKey, f32) {
let middle = phone_size().y / 2.0;
let list = (screen.list)(&mut h.rsc);
let key = list.key_at(middle).expect("a row under the viewport");
let (top, _) = list.extent(key).expect("that row has an extent");
(key, top)
}
fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 {
(screen.list)(&mut h.rsc)
.extent(key)
.expect("the tracked row is still loaded")
.0
}
const STEP: f32 = 2.0;
const SAMPLES: usize = 3;
const CATCH_X: f32 = 540.0;
fn drag_from(
h: &mut Harness,
screen: &ai_app::ui::TranscriptScreen,
key: RowKey,
y0: f32,
t0: u64,
expect_tracking: bool,
) -> u64 {
let before = row_top(h, screen, key);
h.touch(TouchAction::Down, Vec2::new(CATCH_X, y0), t0);
assert_eq!(
row_top(h, screen, key),
before,
"the down itself must not move the content, only stop it"
);
let mut t = t0;
for i in 1..=SAMPLES {
let moved = STEP * i as f32;
t = t0 + 8 * i as u64;
h.touch(TouchAction::Move, Vec2::new(CATCH_X, y0 + moved), t);
let travelled = row_top(h, screen, key) - before;
if expect_tracking {
assert!(
(travelled - moved).abs() < 0.5,
"sample {i}: the finger has moved {moved}px since the down and the content \
{travelled:.1}px -- it is not pinned to the finger"
);
} else {
assert!(
travelled.abs() < 0.5,
"sample {i}: a {moved}px drag is inside DRAG_SLOP ({DRAG_SLOP}px) and must move \
nothing, but the content moved {travelled:.1}px"
);
}
}
t += 8;
h.touch(
TouchAction::Up,
Vec2::new(CATCH_X, y0 + STEP * SAMPLES as f32),
t,
);
t
}
#[test]
fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
let catch_at = flick.end_ms() + 150;
h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
catch_at - PHONE_FRAME_MS,
PHONE_FRAME_MS,
);
assert!(
(screen.list)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing"
);
let (key, _) = tracked_row(&mut h, &screen);
drag_from(&mut h, &screen, key, 1200.0, catch_at, true);
}
#[test]
fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
let catch_at = flick.end_ms() + 150;
h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
catch_at - PHONE_FRAME_MS,
PHONE_FRAME_MS,
);
assert!(
(screen.list)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing"
);
let (key, _) = tracked_row(&mut h, &screen);
h.touch(TouchAction::Down, Vec2::new(CATCH_X, 1200.0), catch_at);
let stopped_at = row_top(&mut h, &screen, key);
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a press that stopped a fling and moved nothing must not start another"
);
h.frames_until(catch_at + 16, catch_at + 500, PHONE_FRAME_MS);
assert!(
(row_top(&mut h, &screen, key) - stopped_at).abs() < 0.5,
"the content moved after a catch was released without moving"
);
assert_eq!(
h.state.opened_urls,
Vec::<String>::new(),
"a catch is not a tap: nothing under it may be followed"
);
}
#[test]
fn the_same_small_drag_on_a_settled_list_moves_nothing() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
let settled = h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
flick.end_ms() + 4000,
PHONE_FRAME_MS,
);
assert!(
!(screen.list)(&mut h.rsc).is_scrolling(),
"the fling must have stopped, or this is the same case as the test above"
);
let (key, _) = tracked_row(&mut h, &screen);
drag_from(
&mut h,
&screen,
key,
1200.0,
settled + PHONE_FRAME_MS,
false,
);
}
+191
View File
@@ -0,0 +1,191 @@
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::{Harness, TouchAction};
use iris::prelude::*;
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
h.render
.active
.keys()
.copied()
.filter(|&id| {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
.is_some_and(|s| s.axis() == Axis::X)
})
.find_map(|id| {
let r = h.render.window_region(&id, &h.rsc)?;
(r.top_left.y >= top && r.bot_right.y <= bottom).then_some((id, r))
})
}
fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
.expect("the fence's scroll area is still drawn")
.is_scrolling()
}
fn amt(h: &Harness, id: WidgetId) -> f32 {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
.expect("the fence's scroll area is still drawn")
.amt()
}
#[test]
fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
let screen = opened.screen;
h.frame(0);
h.frame(PHONE_FRAME_MS);
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: "```\none two three four five six seven eight nine ten eleven twelve \
thirteen fourteen fifteen sixteen seventeen eighteen twenty twentyone\n```"
.to_string(),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
let key = ai_app::ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
.expect("the pushed fence draws a horizontal scroll area of its own");
assert_eq!(amt(&h, fence_scroll), 0.0, "a fence opens at its start");
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
let at_release = amt(&h, fence_scroll);
assert!(
at_release > 0.0,
"the flick itself must have panned the fence, got {at_release}"
);
let mut t = 240;
while t <= 740 {
h.frame(t);
t += PHONE_FRAME_MS;
}
let coasted = amt(&h, fence_scroll);
assert!(
coasted > at_release + 1.0,
"the fence stopped dead at the release: {at_release} -> {coasted}"
);
let settled = coasted;
while t <= 4_000 {
h.frame(t);
t += PHONE_FRAME_MS;
}
let after = amt(&h, fence_scroll);
assert!(
after >= settled,
"a fling must not run backwards: {settled} -> {after}"
);
let last = after;
h.frame(t);
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
}
#[test]
fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
let screen = opened.screen;
h.frame(0);
h.frame(PHONE_FRAME_MS);
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: format!(
"```\n{}\n```",
(1..=200)
.map(|i| format!("word{i}"))
.collect::<Vec<_>>()
.join(" ")
),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
let key = ai_app::ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
.expect("the pushed fence draws a horizontal scroll area of its own");
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
h.frame(248);
assert!(
is_scrolling(&h, fence_scroll),
"the fence has to still be coasting for this to be the reported case",
);
let probe = box_.top_left.y - 500.0;
let row = (screen.list)(&mut h.rsc)
.key_at(probe)
.expect("a row that far up the screen");
let (row_top, row_bottom) = (screen.list)(&mut h.rsc).extent(row).expect("its extent");
let from = (row_top + row_bottom) / 2.0;
let list_before = (screen.list)(&mut h.rsc).anchor_position_display();
let fence_before = amt(&h, fence_scroll);
h.touch(TouchAction::Down, Vec2::new(540.0, from), 256);
let mut t = 264;
for i in 1..=8 {
h.touch(
TouchAction::Move,
Vec2::new(540.0, from + 20.0 * i as f32),
t,
);
t += 8;
}
h.touch(TouchAction::Up, Vec2::new(540.0, from + 160.0), t);
assert_ne!(
list_before,
(screen.list)(&mut h.rsc).anchor_position_display(),
"the drag was nowhere near the fence, so it belongs to the list",
);
assert!(
amt(&h, fence_scroll) > fence_before,
"the fence's fling must carry on through a gesture that was never \
its own: {fence_before} -> {}",
amt(&h, fence_scroll),
);
}
+150
View File
@@ -0,0 +1,150 @@
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::{Harness, TouchAction, TouchScript};
use iris::prelude::*;
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
fn script(name: &str, text: &str) -> TouchScript {
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
}
fn tracked_row(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> (RowKey, f32) {
let middle = phone_size().y / 2.0;
let list = (screen.list)(&mut h.rsc);
let key = list.key_at(middle).expect("a row under the viewport");
let (top, _) = list.extent(key).expect("that row has an extent");
(key, top)
}
fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 {
(screen.list)(&mut h.rsc)
.extent(key)
.expect("the tracked row is still loaded")
.0
}
#[test]
fn a_cancelled_flick_does_not_fling() {
let (mut h, screen) = opened();
let flick = script(
"flick-cancelled",
include_str!("../touch/flick-cancelled.touch"),
);
h.replay(&flick);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a gesture the platform took away must not fling"
);
let (key, settled) = tracked_row(&mut h, &screen);
let end = flick.end_ms() + 1_000;
let mut t = flick.end_ms();
while t <= end {
h.frame(t);
t += PHONE_FRAME_MS;
}
let now = row_top(&mut h, &screen, key);
assert!(
(now - settled).abs() < 0.5,
"the list kept moving after a cancelled gesture: {settled} -> {now}"
);
}
#[test]
fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
let (mut h, screen) = opened();
h.touch(TouchAction::Down, Vec2::new(540.0, 700.0), 0);
h.touch(TouchAction::Cancel, Vec2::new(540.0, 700.0), 8);
let (key, before) = tracked_row(&mut h, &screen);
h.touch(TouchAction::Down, Vec2::new(540.0, 1900.0), 200);
h.touch(TouchAction::Up, Vec2::new(540.0, 1900.0), 250);
let after = row_top(&mut h, &screen, key);
assert!(
(after - before).abs() < 0.5,
"a tap after a cancelled press panned the list by {}px, the distance between them",
after - before
);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"and it must not have flung either"
);
}
#[test]
fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
let (mut h, screen) = opened();
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: "```\none two three four five six seven eight nine ten eleven twelve\n\
thirteen fourteen fifteen sixteen seventeen eighteen nineteen\n```"
.to_string(),
settled: true,
});
let para = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_001,
text: "A plain paragraph with nothing to tap in it, only words, so that a \
press here is a press on ordinary text and nothing else."
.to_string(),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
screen.push_row(&mut h.rsc, &para);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
let key = ai_app::ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let y = (top + bottom) / 2.0;
assert!(
y > 0.0 && y < phone_size().y,
"the fence row has to be on screen to be pressed: {top}..{bottom}"
);
h.touch(TouchAction::Down, Vec2::new(800.0, y), 200);
for (i, x) in [760.0, 700.0, 620.0, 540.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(540.0, y), 248);
let (tracked, before) = tracked_row(&mut h, &screen);
let para_key = ai_app::ui::row::row_key(&para.key());
let (ptop, pbottom) = (screen.list)(&mut h.rsc)
.extent(para_key)
.expect("the paragraph row is on screen");
h.touch(
TouchAction::Down,
Vec2::new(540.0, (ptop + pbottom) / 2.0),
400,
);
h.touch(
TouchAction::Up,
Vec2::new(540.0, (ptop + pbottom) / 2.0),
450,
);
let after = row_top(&mut h, &screen, tracked);
assert!(
(after - before).abs() < 0.5,
"a tap after panning a code fence moved the transcript by {}px",
after - before
);
}
+144
View File
@@ -0,0 +1,144 @@
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::{Harness, TouchScript};
struct CaptureLogger {
lines: Mutex<Vec<(log::Level, String)>>,
}
static LOGGER: OnceLock<CaptureLogger> = OnceLock::new();
impl log::Log for CaptureLogger {
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
self.lines
.lock()
.unwrap()
.push((record.level(), record.args().to_string()));
}
fn flush(&self) {}
}
fn logger() -> &'static CaptureLogger {
let logger = LOGGER.get_or_init(|| CaptureLogger {
lines: Mutex::new(Vec::new()),
});
let _ = log::set_logger(logger);
log::set_max_level(log::LevelFilter::Debug);
logger
}
fn drain(logger: &CaptureLogger) -> Vec<(log::Level, String)> {
std::mem::take(&mut *logger.lines.lock().unwrap())
}
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
#[test]
fn tracing_is_silent_off_and_round_trips_the_flick_on() {
let logger = logger();
iris::diagnostics::set_trace(false);
drain(logger); // whatever `opened()` itself logged while building
let (mut h, screen) = opened();
drain(logger); // and whatever opening logged
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
let _ = (screen.list)(&mut h.rsc); // touch the screen the same way a real caller would
let quiet = drain(logger);
let debug_lines: Vec<_> = quiet
.iter()
.filter(|(level, _)| *level == log::Level::Debug)
.collect();
assert!(
debug_lines.is_empty(),
"tracing is off, but the ring would still have held these `debug!` lines: {debug_lines:#?}"
);
iris::diagnostics::set_trace(true);
let (mut h, screen) = opened();
drain(logger);
h.replay(&flick);
let _ = (screen.list)(&mut h.rsc);
let traced = drain(logger);
iris::diagnostics::set_trace(false); // leave it off for any test after this one
let input_lines: Vec<&str> = traced
.iter()
.filter(|(_, msg)| msg.contains("iris input: action="))
.map(|(_, msg)| msg.as_str())
.collect();
assert_eq!(
input_lines.len(),
flick.samples.len(),
"expected one `iris::input` line per replayed sample, got:\n{input_lines:#?}"
);
let frame_lines: Vec<&str> = traced
.iter()
.filter(|(_, msg)| msg.starts_with("iris frame:"))
.map(|(_, msg)| msg.as_str())
.collect();
assert!(
!frame_lines.is_empty(),
"expected at least one `iris::frame` line once tracing was on"
);
for line in &frame_lines {
assert!(
!line.contains("layout=0ns"),
"a frame that redrew should not report zero layout time: {line}"
);
}
let report = input_lines.join("\n");
let script_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../iris/benches/report_to_touch.py"
);
let mut child = Command::new("python3")
.arg(script_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("python3 must be on PATH to run report_to_touch.py");
{
use std::io::Write;
child
.stdin
.take()
.unwrap()
.write_all(report.as_bytes())
.unwrap();
}
let output = child.wait_with_output().expect("report_to_touch.py exited");
assert!(
output.status.success(),
"report_to_touch.py failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let touch_text = String::from_utf8(output.stdout).expect("report_to_touch.py wrote UTF-8");
let round_tripped =
TouchScript::parse(&touch_text).unwrap_or_else(|e| panic!("round-tripped script: {e}"));
assert_eq!(
round_tripped.samples.len(),
flick.samples.len(),
"round trip produced a different number of samples:\n{touch_text}"
);
for (original, back) in flick.samples.iter().zip(round_tripped.samples.iter()) {
assert_eq!(original.t_ms, back.t_ms);
assert_eq!(original.action, back.action);
assert_eq!(original.pos, back.pos);
}
}
+265
View File
@@ -0,0 +1,265 @@
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::{Harness, TouchScript};
use iris::prelude::*;
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
fn script(name: &str, text: &str) -> TouchScript {
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
}
fn offset(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> String {
(screen.list)(&mut h.rsc).anchor_position_display()
}
#[test]
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
h.replay(&flick);
let velocity = (screen.list)(&mut h.rsc)
.fling_velocity()
.expect("the flick must release as a pan with a velocity, not a tap");
assert!(
(velocity - 15_250.0).abs() < 20.0,
"expected ~15250px/s from velocity_reference.py, got {velocity}"
);
const REFERENCE_MS: u64 = 2071;
const REFERENCE_PX: f32 = 11057.0;
let end = flick.end_ms() + REFERENCE_MS * 2;
let mut settled_at = None;
let mut t = flick.end_ms();
let middle = phone_size().y / 2.0;
let mut travelled = 0.0f32;
let mut tracked: Option<(RowKey, f32)> = None;
while t <= end {
h.frame(t);
let list = (screen.list)(&mut h.rsc);
tracked =
match tracked.and_then(|(key, was)| list.extent(key).map(|(now, _)| (key, was, now))) {
Some((key, was, now)) => {
travelled += (now - was).abs();
Some((key, now))
}
None => list
.key_at(middle)
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
};
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
settled_at = Some(t);
}
t += PHONE_FRAME_MS;
}
let after = offset(&mut h, &screen);
assert_ne!(
before, after,
"the fling ticks must have moved the list off where the flick left it"
);
let settled_at = settled_at.expect("the fling must stop on its own, not run forever");
let ran_for = settled_at - flick.end_ms();
assert!(
ran_for >= REFERENCE_MS - PHONE_FRAME_MS * 2,
"the fling stopped after {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
);
assert!(
ran_for <= REFERENCE_MS + PHONE_FRAME_MS * 2,
"the fling ran {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
);
assert!(
travelled >= REFERENCE_PX * 0.8,
"the fling travelled {travelled:.0}px against the spline reference's {REFERENCE_PX:.0}px"
);
}
#[test]
fn a_tap_on_a_row_moves_nothing() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
h.replay(&script("tap", include_str!("../touch/tap.touch")));
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a tap must not fling"
);
h.frames_until(100, 400, PHONE_FRAME_MS);
assert_eq!(before, offset(&mut h, &screen), "a tap must scroll nothing");
assert_eq!(
h.state.opened_urls,
Vec::<String>::new(),
"no link was under this tap"
);
}
#[test]
fn a_long_press_and_drag_selects_text() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
h.replay(&script(
"long-press",
include_str!("../touch/long-press.touch"),
));
let selected = screen
.selected_text(&mut h.rsc)
.expect("a long-press then drag must leave text selected");
assert!(
!selected.trim().is_empty(),
"the selection covered no characters: {selected:?}"
);
assert_eq!(
before,
offset(&mut h, &screen),
"a selection must not also pan the list"
);
}
#[test]
fn a_tap_after_selection_deselects_text() {
let (mut h, screen) = opened();
h.replay(&script(
"select-then-tap",
"0 down 300 1000\n\
520 move 300 1000\n\
560 move 700 1000\n\
600 move 900 1000\n\
640 up 900 1000\n\
800 down 540 1000\n\
880 up 540 1000",
));
assert_eq!(
screen.selected_text(&mut h.rsc),
None,
"an ordinary tap after a selection must dismiss it"
);
assert_eq!(
h.state.opened_urls,
Vec::<String>::new(),
"the deselecting tap must be consumed rather than activating content"
);
}
#[test]
fn the_composer_sits_above_a_simulated_ime_inset() {
let (mut h, screen) = opened();
let height = h.size().y;
let field_bottom = |h: &mut Harness| {
h.render
.window_region(&screen.composer.field, &h.rsc)
.expect("the composer field is on screen")
.bot_right
.y
};
let closed = field_bottom(&mut h);
assert!(
closed <= height,
"the composer is off the bottom of the window even with no keyboard: {closed} > {height}"
);
let ime = 1000.0;
screen.composer.set_bottom_inset(&mut h.rsc, ime);
h.frame(PHONE_FRAME_MS * 2);
let open = field_bottom(&mut h);
assert!(
open <= height - ime,
"the keyboard covers the composer: its bottom is at {open}, the IME starts at {}",
height - ime
);
assert!(
(closed - open - ime).abs() < 1.0,
"the composer moved {} for a {ime}px inset",
closed - open
);
}
#[test]
fn a_space_in_the_composer_finishes_layout() {
let (mut h, screen) = opened();
screen.composer.set_bottom_inset(&mut h.rsc, 1000.0);
h.frame(PHONE_FRAME_MS * 2);
h.state.set_focus(Some(screen.composer.field));
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
for text in ["h", "i", " "] {
screen.composer.field.edit(&mut h.rsc).insert(text);
h.frame(PHONE_FRAME_MS);
}
assert_eq!(screen.composer.field.edit(&mut h.rsc).text.text(), "hi ");
let region = h
.render
.window_region(&screen.composer.field, &h.rsc)
.expect("the composer field is drawn");
let size = region.bot_right - region.top_left;
assert!(
size.x > phone_size().x / 2.0,
"the field shrink-wrapped to the short message: {region:?}"
);
assert!(
size.y < 30.0 * PHONE_SCALE,
"the trailing space wrapped onto a second line: {region:?}"
);
}
#[test]
fn a_newline_leaves_the_caret_inside_the_composers_padding() {
let (mut h, screen) = opened();
let height = h.size().y;
let ime = 1000.0;
screen.composer.set_bottom_inset(&mut h.rsc, ime);
h.frame(PHONE_FRAME_MS * 2);
h.state.set_focus(Some(screen.composer.field));
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
for _ in 0..12 {
screen.composer.field.edit(&mut h.rsc).insert("a\n");
h.frame(PHONE_FRAME_MS);
}
let message = h
.render
.debug(h.rsc.widgets(), "Message")
.find(|a| !a.primitives.is_empty())
.expect("the composer field is drawn");
let caret = h
.render
.primitive_corners(message.primitives.last().unwrap().slot, &h.rsc);
let bar_bottom = height - ime;
let padding = 12.0 * PHONE_SCALE;
assert!(
caret.bot_right.y < bar_bottom - padding / 2.0,
"the caret is in the bar's bottom padding: it ends at {}, the bar's edge is {bar_bottom} \
and its padding is {padding}px",
caret.bot_right.y,
);
let mask = h.rsc.ui.masks[message.mask.idx()];
let bar = h.render.primitive_corners(mask.primitive, &h.rsc);
let visible_content_top = message
.primitives
.iter()
.map(|p| h.render.primitive_corners(p.slot, &h.rsc))
.filter(|r| r.bot_right.y > bar.top_left.y)
.map(|r| r.top_left.y)
.fold(f32::INFINITY, f32::min);
assert!(
visible_content_top > bar.top_left.y,
"the composer's first visible line is clipped above its bar: content starts at {visible_content_top}, bar starts at {}",
bar.top_left.y,
);
}
+249
View File
@@ -0,0 +1,249 @@
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::Harness;
use iris::prelude::*;
const HEADER_H: f32 = 300.0;
const HEADER: Srgba8 = Srgba8::new(28, 28, 34, 255);
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let (opened, tree) = ai_app::ui::fixture::build_screen(&mut h.rsc).expect("the fixture folds");
let content = WidgetPtr::new().add(&mut h.rsc);
content(&mut h.rsc).set(tree);
let root = (rect(HEADER).height(abs(HEADER_H)), content.height(rest(1)))
.span(Dir::DOWN)
.add_strong(&mut h.rsc)
.any();
h.state.set_root(&mut h.rsc, root);
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
fn list_box(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> PixelRegion {
h.render
.window_region(&screen.list.id(), &h.rsc)
.expect("the list is on screen")
}
fn drawn_rows(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> Vec<(f32, f32)> {
let mut rows: Vec<(f32, f32)> = h
.render
.active
.get(&screen.list.id())
.expect("the list is drawn")
.children
.iter()
.filter_map(|id| h.render.window_region(id, &h.rsc))
.map(|px| (px.top_left.y, px.bot_right.y))
.collect();
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
rows
}
fn scrolled(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
(screen.list)(&mut h.rsc).scroll(amount);
h.frame(t);
t + PHONE_FRAME_MS
}
#[test]
fn the_row_across_the_top_edge_is_drawn() {
let (mut h, screen) = opened();
let top = list_box(&h, &screen).top_left.y;
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..60 {
t = scrolled(&mut h, &screen, 40.0, t);
let rows = drawn_rows(&h, &screen);
let first = *rows.first().expect("something is on screen");
assert!(
first.0 <= top + 0.5,
"a band of {:.1}px under the header belongs to no row: rows start at {:.1}, the list \
at {top:.1}",
first.0 - top,
first.0,
);
assert!(
first.1 > top,
"the row across the top edge was culled: it ends at {:.1}, above the list's own \
{top:.1}",
first.1,
);
}
}
#[test]
fn the_list_is_clipped_to_its_own_box() {
let (h, screen) = opened();
let active = h.render.active.get(&screen.list.id()).expect("drawn");
assert!(
active.mask != MaskIdx::NONE,
"the transcript's list is drawn with nothing clipping it",
);
let clip = h.render.mask_region(active.mask, &h.rsc);
let list = list_box(&h, &screen);
assert!(
clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5,
"the clip {clip:?} reaches outside the list's own box {list:?}, so a row straddling an \
edge still draws past it",
);
let rows = h
.render
.active
.get(&screen.list.id())
.expect("the list is drawn")
.children
.clone();
let mut checked = 0;
for row in rows {
for prim in primitives_under(&h, row) {
assert!(
mask_chain(&h, prim).contains(&active.mask),
"a primitive of row {row:?} clips to {:?}, a chain that never reaches the list's \
own mask {:?}",
mask_chain(&h, prim),
active.mask,
);
checked += 1;
}
}
assert!(
checked > 0,
"no row primitive was checked, so this test asserted nothing",
);
}
fn primitives_under(h: &Harness, id: WidgetId) -> Vec<MaskIdx> {
let Some(active) = h.render.active.get(&id) else {
return Vec::new();
};
let mut out: Vec<MaskIdx> = active
.primitives
.iter()
.filter(|p| p.binding != IMAGE_BINDING)
.map(|p| h.render.primitives.instance(p.slot).mask_idx)
.collect();
for child in &active.children {
out.extend(primitives_under(h, *child));
}
out
}
fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
let mut chain = Vec::new();
let mut at = mask;
while at != MaskIdx::NONE {
assert!(
!chain.contains(&at),
"the mask chain from {mask:?} loops back to {at:?}",
);
chain.push(at);
at = h.rsc.ui.masks[at.idx()].parent;
}
chain
}
#[test]
fn rows_that_have_left_the_viewport_are_not_drawn() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
let bounded = |rows: &[(f32, f32)], leg: &str, step: usize| {
assert!(
rows.len() <= 24,
"{leg} {step}: {} rows drawn for one 2012px viewport",
rows.len(),
);
};
let inside = |rows: &[(f32, f32)], leg: &str, step: usize| {
for &(top, bottom) in rows {
assert!(
bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5,
"{leg} {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \
{list:?} and was drawn anyway",
);
}
};
for step in 0..40 {
t = scrolled(&mut h, &screen, 400.0, t);
bounded(&drawn_rows(&h, &screen), "measuring", step);
}
for step in 0..40 {
t = scrolled(&mut h, &screen, -400.0, t);
let rows = drawn_rows(&h, &screen);
bounded(&rows, "forward", step);
inside(&rows, "forward", step);
}
for step in 0..40 {
t = scrolled(&mut h, &screen, 400.0, t);
let rows = drawn_rows(&h, &screen);
bounded(&rows, "back", step);
inside(&rows, "back", step);
}
}
#[test]
fn the_row_across_the_bottom_edge_is_drawn() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..40 {
t = scrolled(&mut h, &screen, 37.0, t);
let rows = drawn_rows(&h, &screen);
let last = *rows.last().expect("something is on screen");
assert!(
last.1 >= list.bot_right.y - 0.5,
"a band of {:.1}px above the composer belongs to no row",
list.bot_right.y - last.1,
);
assert!(
last.0 < list.bot_right.y,
"the row across the bottom edge was culled: it starts at {:.1}, below the list's own \
{:.1}",
last.0,
list.bot_right.y,
);
}
}
#[test]
fn scrolling_past_the_first_row_settles_on_it() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..60 {
t = scrolled(&mut h, &screen, 100_000.0, t);
}
let rows = drawn_rows(&h, &screen);
let first = *rows.first().expect("the first row is on screen");
assert!(
(first.0 - list.top_left.y).abs() < 0.5,
"the transcript is parked {:.1}px past its own first row, so the top of the list is blank",
first.0 - list.top_left.y,
);
}
#[test]
fn scrolling_past_the_last_row_settles_on_it() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..20 {
t = scrolled(&mut h, &screen, -100_000.0, t);
}
let rows = drawn_rows(&h, &screen);
let last = *rows.last().expect("the last row is on screen");
assert!(
(last.1 - list.bot_right.y).abs() < 0.5,
"the transcript is parked {:.1}px past its own last row, so the bottom of the list is \
blank",
list.bot_right.y - last.1,
);
}
+21
View File
@@ -0,0 +1,21 @@
# A finger flick the shape Iris's phone delivers one, from
# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's
# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app
# as DOWN, one or two MOVEs and UP inside a few frames, with the
# intermediate positions batched inside those MOVEs as historical
# samples (~4ms apart, the touch digitiser's own rate) rather than
# arriving as separate events. Each line here is one such sample, which
# is exactly what `IrisViewPeer::on_touch_event` replays through the
# sensors one at a time -- so the whole gesture is 20ms and five
# samples, and the velocity has to come out of *those*.
#
# Downward (increasing y) on purpose: the screen opens pinned to the
# newest end, so a flick the other way has nothing left to scroll to and
# the fling clamps on its first tick -- a pass that would prove nothing.
# Coordinates are physical pixels on a 1080x2424 surface.
0 down 540 1000
4 move 540 1040
8 move 540 1086
12 move 540 1138
16 move 540 1196
20 up 540 1196
+11
View File
@@ -0,0 +1,11 @@
# `flick-120hz.touch` to the sample, with the platform taking the gesture
# away instead of the finger lifting -- Android's `ACTION_CANCEL`, which
# is what the swipe up from the bottom edge to leave the app delivers
# after its moves. Nothing may follow from it: no tap, no selection and,
# the one that showed on Iris's phone, no fling.
0 down 540 1000
4 move 540 1040
8 move 540 1086
12 move 540 1138
16 move 540 1196
20 cancel 540 1196
+11
View File
@@ -0,0 +1,11 @@
# A long-press then a drag across the text: held past LONG_PRESS
# (500ms) without moving, which is what starts a selection rather than a
# pan, then dragged sideways so the selection actually covers
# something. A press alone leaves a collapsed caret and no selected
# text (`Selection::begin`), which is why this file does not stop at the
# hold.
0 down 300 1000
520 move 300 1000
560 move 700 1000
600 move 900 1000
640 up 900 1000
+5
View File
@@ -0,0 +1,5 @@
# The case the flick had no reason to touch: a press and release in one
# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a
# tap -- no pan, no velocity, nothing moved.
0 down 540 1000
80 up 540 1000
@@ -280,7 +280,7 @@ fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
* One row of `GET /sessions/{id}/subagents`, oldest first.
*
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
* this carries only what a card needs to draw and to open it; see SUBAGENTS.md. [status] is
* this carries only what a card needs to draw and to open it; see docs/SUBAGENTS.md. [status] is
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
* running, and the list says so rather than reporting a state that cannot hold.
*/
@@ -49,7 +49,7 @@ private sealed class Screen {
/**
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* docs/SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* is opened from the session list's card rather than from inside the session it belongs to.
*/
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
@@ -4,8 +4,8 @@ import android.content.Context
import java.util.concurrent.CopyOnWriteArrayList
/**
* P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): an in-process
* fake of the backend, so the `bench` build type can drive a real session screen -- the real
* P0's benchmark gate (see docs/RUST.md and the 2026-09-05 decision): an in-process fake of the
* backend, so the `bench` build type can drive a real session screen -- the real
* [TranscriptSource], the real fold, the real paging -- with no server and no network permission.
*
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
@@ -24,7 +24,7 @@ object BenchFixture {
const val FIXTURE_PORT = 1
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
private const val BACKLOG_COUNT = 3200
private const val BACKLOG_COUNT = 3202
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
@@ -3,9 +3,13 @@ package com.example.aiapp
import android.content.Context
import android.os.BatteryManager
import android.os.Process
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import android.view.View
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.focus.FocusRequester
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
@@ -19,27 +23,84 @@ import kotlinx.coroutines.launch
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
* [BenchFixture]'s doc comment gives.
*
* **v2 (2026-09-06)**, asked for by Iris because the v1 fling was too gentle to stress-test the
* scroll path and said nothing about typing or the keyboard. Four phases now, each a slice of the
* same [FrameStats] recording ([FrameStats.markPhase]/[FrameStats.phaseLines] -- one recorder, not
* two): **fling** (real `FlingBehavior`, not `animateScrollBy`), **stream** (unchanged from v1),
* **type** (600 fixed characters into the real composer `TextFieldValue`, then deleted), and
* **keyboard** (five show/hide cycles). The exact constants below are also written into
* `docs/RUST.md`'s P0 box, "Benchmark v2 (2026-09-06)", so the iris half implements the identical
* spec -- changing a number here without updating that box makes the two apps measure different
* things while looking like the same benchmark.
*/
object BenchRun {
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, 900px over 200ms, 500ms apart. */
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, kept as the pre-v2 comparison. */
private const val CYCLES = 6
private const val SWIPE_PX = 900f
private const val SWIPE_MS = 200
private const val SWIPE_PAUSE_MS = 500L
/**
* Fling phase (v2): a real fling through the list's own [FlingBehavior], not `animateScrollBy`
* -- Iris's ask was that it "travel way faster" than the old tween-based swipe, and a tween can
* never exceed the distance it is told to cover in the time it is given, while a real fling
* decays from an initial velocity the way a finger flick does. 12,000 px/s is roughly a hard,
* fast flick on a ~420dp/in device (about 30 dp/ms-equivalent initial speed); chosen well above
* the ~4,500 px/s a moderate `animateScrollBy` swipe implies, so this phase exercises the fast
* end of what the platform's fling decay produces rather than the gentle one v1 measured.
*/
private const val FLING_VELOCITY_PX_S = 12_000f
private const val FLING_COUNT = 8
private const val FLING_SETTLE_CAP_MS = 3_000L
private const val FLING_PAUSE_MS = 300L
/** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */
private const val STREAM_EVENTS_PER_SEC = 20
private const val STREAM_SECONDS = 20
/**
* Scrolls, then streams, then returns the extra report lines P0 asked for (CPU time, peak RSS,
* battery current) -- [FrameStats] and [DebugStats] are reset first, exactly as
* `copyRenderReport` resets them, so the two accountings cover the same stretch of work.
* Type phase (v2): sentences built from long, multisyllabic words so the composer actually
* wraps across lines rather than fitting one, and long enough (600 chars) that the composer's
* own height grows over several frames, pushing the transcript above it upward the same way a
* real long message does. Exactly this string is also in `docs/RUST.md`'s P0 box so the iris
* half types the identical content.
*/
const val TYPE_TEXT =
"Benchmarking this transcript screen requires unusually long, multisyllabic words so " +
"wrapping and reflow are properly exercised: internationalization, " +
"counterproductiveness, disproportionately, incomprehensibility, " +
"deinstitutionalization, uncharacteristically, overenthusiastically, " +
"misunderstanding, straightforwardness, telecommunications, and interdisciplinary " +
"collaboration all push a narrow composer field to wrap across several lines while " +
"the transcript above is pushed upward by the growing keyboard-adjacent box, which " +
"is exactly what a real reader typing a long message sees happening now!!!"
private const val TYPE_CHAR_DELAY_MS = 50L
/**
* Keyboard phase (v2): five show/hide cycles, a second apart, is enough to see whether the
* transition is ever actually observed rather than being a one-off fluke either way.
*/
private const val KEYBOARD_CYCLES = 5
private const val KEYBOARD_SHOW_WAIT_MS = 1_000L
private const val KEYBOARD_HIDE_WAIT_MS = 1_000L
/**
* Scrolls, flings, streams, types and toggles the keyboard, then returns the extra report lines
* P0 asked for (per-phase travel/typing/keyboard counts, plus CPU time, peak RSS, battery
* current) -- [FrameStats] and [DebugStats] are reset first, exactly as `copyRenderReport`
* resets them, so the two accountings cover the same stretch of work.
*/
suspend fun run(
context: Context,
scope: CoroutineScope,
listState: LazyListState,
flingBehavior: FlingBehavior,
composerFocus: FocusRequester,
setComposerText: (String) -> Unit,
view: View,
): List<String> {
FrameStats.reset()
DebugStats.reset()
@@ -55,34 +116,10 @@ object BenchRun {
}
}
// The swipe loop: transcript-bench.sh's four swipes per cycle are two drags toward newer
// content and two back, so a cycle returns to where it started and the whole loop measures
// steady-state scrolling rather than travelling somewhere new each time.
repeat(CYCLES) {
repeat(2) {
listState.animateScrollBy(SWIPE_PX, tween(SWIPE_MS))
delay(SWIPE_PAUSE_MS)
}
repeat(2) {
listState.animateScrollBy(-SWIPE_PX, tween(SWIPE_MS))
delay(SWIPE_PAUSE_MS)
}
}
// Pinned to the newest end before streaming starts, the way stream-bench.sh's "Jump to
// latest" tap is -- a reply streamed into a list parked further back arrives off-screen and
// the report would show nothing happened.
listState.scrollToItem(0)
var sent = 0
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
BenchFixture.pushNextLiveEvent()
sent++
delay(1000L / STREAM_EVENTS_PER_SEC)
}
// Lets the last few deltas land and draw before the report is read.
delay(300)
val travel = runFlingPhase(listState, flingBehavior)
val sent = runStreamPhase()
runTypePhase(listState, composerFocus, setComposerText, view)
val keyboard = runKeyboardPhase(context, view)
samplerJob.cancel()
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
@@ -90,13 +127,158 @@ object BenchRun {
val batteryLine = battery.finish()
return listOf(
" scroll: $CYCLES cycles (${CYCLES * 4} swipes), streamed $sent/$total fixture events",
" fling: $FLING_COUNT flings out + $FLING_COUNT back at" +
" ${FLING_VELOCITY_PX_S.toInt()}px/s, travel $travel",
" scroll: $CYCLES cycles (${CYCLES * 4} swipes, legacy tween), " +
"streamed $sent/${STREAM_EVENTS_PER_SEC * STREAM_SECONDS} fixture events",
" type: ${TYPE_TEXT.length} characters inserted then deleted, one per" +
" ${TYPE_CHAR_DELAY_MS}ms",
keyboard,
" process CPU time over this run: ${cpuMs}ms",
rssLine,
batteryLine,
)
}
/**
* Phase 1: starting pinned at the newest end, [FLING_COUNT] flings away from it (toward older
* messages) through the list's real fling path, then [FLING_COUNT] back. Positive velocity here
* matches this list's existing scroll-offset convention (`TranscriptList`'s `reverseLayout`
* pins index 0 -- the newest item -- at the bottom; a positive scroll offset moves the viewport
* toward higher indices, i.e. away from the newest end and toward older content), the same sign
* the pre-v2 swipe loop below already used for its first two swipes.
*/
private suspend fun runFlingPhase(
listState: LazyListState,
flingBehavior: FlingBehavior,
): String {
FrameStats.markPhase("fling")
listState.scrollToItem(0)
val start = position(listState)
repeat(FLING_COUNT) {
listState.scroll { with(flingBehavior) { performFling(FLING_VELOCITY_PX_S) } }
waitForSettle(listState)
delay(FLING_PAUSE_MS)
}
val outward = position(listState)
repeat(FLING_COUNT) {
listState.scroll { with(flingBehavior) { performFling(-FLING_VELOCITY_PX_S) } }
waitForSettle(listState)
delay(FLING_PAUSE_MS)
}
val back = position(listState)
return "start=$start outward=$outward end=$back"
}
private fun position(listState: LazyListState) =
"idx=${listState.firstVisibleItemIndex}/off=${listState.firstVisibleItemScrollOffset}px"
/** Belt-and-suspenders on top of `performFling` already suspending until its own decay ends. */
private suspend fun waitForSettle(listState: LazyListState) {
val startedAt = System.currentTimeMillis()
while (
listState.isScrollInProgress &&
System.currentTimeMillis() - startedAt < FLING_SETTLE_CAP_MS
) {
delay(16)
}
}
/**
* Phase 2 (unchanged from v1): pinned to the newest end before streaming starts, the way
* stream-bench.sh's "Jump to latest" tap is -- a reply streamed into a list parked further back
* arrives off-screen and the report would show nothing happened.
*/
private suspend fun runStreamPhase(): Int {
FrameStats.markPhase("stream")
var sent = 0
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
BenchFixture.pushNextLiveEvent()
sent++
delay(1000L / STREAM_EVENTS_PER_SEC)
}
// Lets the last few deltas land and draw before the next phase starts.
delay(300)
return sent
}
/**
* Phase 3: focuses the real composer, shows the keyboard if the platform allows it, then types
* [TYPE_TEXT] one character at a time through the same `TextFieldValue` state a real keystroke
* updates, and deletes it the same way -- this is what exercises wrapping and the transcript
* being pushed upward, not a single big write.
*/
private suspend fun runTypePhase(
listState: LazyListState,
composerFocus: FocusRequester,
setComposerText: (String) -> Unit,
view: View,
) {
FrameStats.markPhase("type")
listState.scrollToItem(0)
composerFocus.requestFocus()
showIme(view.context, view)
// Lets focus and the keyboard's opening animation land before typing starts, so the frames
// this phase records are the wrap/reflow it is measuring, not the keyboard opening.
delay(300)
var typed = ""
for (ch in TYPE_TEXT) {
typed += ch
setComposerText(typed)
delay(TYPE_CHAR_DELAY_MS)
}
delay(200)
while (typed.isNotEmpty()) {
typed = typed.dropLast(1)
setComposerText(typed)
delay(TYPE_CHAR_DELAY_MS)
}
}
/**
* Phase 4: [KEYBOARD_CYCLES] show/hide cycles through the same [WindowInsetsControllerCompat]
* path a real IME toggle goes through, reporting how many of each were actually confirmed by
* [android.view.WindowInsets.isVisible] rather than assumed from having asked -- UI_RULES:
* never present an inferred value as a measured one. If the platform never shows it even once,
* this says so in words rather than reporting a phase with no keyboard in it.
*/
private suspend fun runKeyboardPhase(context: Context, view: View): String {
FrameStats.markPhase("keyboard")
var shown = 0
var hidden = 0
repeat(KEYBOARD_CYCLES) {
showIme(context, view)
delay(KEYBOARD_SHOW_WAIT_MS)
if (imeVisible(view)) shown++
hideIme(context, view)
delay(KEYBOARD_HIDE_WAIT_MS)
if (!imeVisible(view)) hidden++
}
return if (shown == 0) {
" keyboard: could not be shown ($KEYBOARD_CYCLES attempts, 0 confirmed visible)"
} else {
" keyboard: shown $shown/$KEYBOARD_CYCLES, hidden $hidden/$KEYBOARD_CYCLES" +
" (confirmed via isImeVisible)"
}
}
private fun controller(context: Context, view: View): WindowInsetsControllerCompat? {
val window = context.activity()?.window ?: return null
return WindowInsetsControllerCompat(window, view)
}
private fun showIme(context: Context, view: View) {
controller(context, view)?.show(WindowInsetsCompat.Type.ime())
}
private fun hideIme(context: Context, view: View) {
controller(context, view)?.hide(WindowInsetsCompat.Type.ime())
}
private fun imeVisible(view: View): Boolean =
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
private fun peakRssLine(): String {
val kb =
@@ -134,6 +134,13 @@ fun debugReport(
* render-report button reads exactly as it did before this existed.
*/
extra: List<String> = emptyList(),
/**
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
* [extra].
*/
phaseFrames: List<String> = emptyList(),
): String = buildString {
appendLine("ai-app render report")
appendLine(device)
@@ -148,6 +155,11 @@ fun debugReport(
appendLine("transcript:")
transcript.forEach { appendLine(it) }
appendLine()
if (phaseFrames.isNotEmpty()) {
appendLine("per phase:")
phaseFrames.forEach { appendLine(it) }
appendLine()
}
appendLine("frames:")
frames.forEach { appendLine(it) }
appendLine()
@@ -42,6 +42,21 @@ object FrameStats {
private val gpu = ArrayList<Long>()
private var since = System.currentTimeMillis()
/**
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
*/
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
private val phaseMarks = ArrayList<PhaseMark>()
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
@Synchronized
fun markPhase(name: String) {
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
}
@Synchronized
fun add(metrics: FrameMetrics) {
// The first frame after a window opens includes inflating it and is nobody's scroll.
@@ -69,6 +84,7 @@ object FrameStats {
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
it.clear()
}
phaseMarks.clear()
since = System.currentTimeMillis()
}
@@ -95,6 +111,38 @@ object FrameStats {
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
}
/**
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
* that finished before a frame was drawn) still get a line rather than being silently dropped
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
*/
@Synchronized
fun phaseLines(refreshHz: Float): List<String> {
if (phaseMarks.isEmpty()) return emptyList()
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
val lines = ArrayList<String>()
phaseMarks.forEachIndexed { i, mark ->
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
val endMs =
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
else System.currentTimeMillis()
val samples = total.subList(mark.startIndex, endIndex)
val seconds = (endMs - mark.startMs) / 1000.0
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
if (samples.isEmpty()) {
lines += " no frames recorded in this phase"
} else {
val late = samples.count { it / 1_000_000.0 > budget }
lines += " late: $late (${percent(late, samples.size)})"
lines += " " + phase("total ", samples)
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
}
}
return lines
}
/** How long the frames recorded here spent in their draw phase, and how many there were. */
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
@@ -187,13 +187,20 @@ class MainActivity : ComponentActivity() {
model = null,
keepsOwnTranscript = false,
permissionMode = null,
effort = null,
takesEffort = false,
imported = false,
notify = false,
autoResume = false,
autoResumeMessage = "",
resumeAt = null,
cwd = null,
contextTokens = null,
maxImageEdge = null,
usageProvider = null,
status = "idle",
lastActivity = 0.0,
subagents = 0,
)
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
@@ -478,7 +478,7 @@ private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
}
/**
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
* The subcard's word for a subagent's status -- see docs/SUBAGENTS.md's "Wire shape". Its own function
* rather than a branch inside [StatusText], because a subagent's three states are not that
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
* and never something of its own to have merely stopped.
@@ -12,6 +12,7 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
@@ -64,12 +65,15 @@ import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextRange
@@ -291,6 +295,12 @@ fun SessionScreen(
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
// note opened and scrolled past is still open on the way back.
var openMemories by remember { mutableStateOf(setOf<String>()) }
// Which capped blocks the reader has asked to see whole: a tool call's input or output
// ([Capped]), and long messages, by the row key that identifies them. Held here rather than in
// the card or the row for `openMemories`' reason -- something opened and scrolled past is still
// open on the way back, and a lazy list drops the composition of anything off screen.
var shownWholeCalls by remember { mutableStateOf(setOf<Capped>()) }
var shownWholeRows by remember { mutableStateOf(setOf<Any>()) }
// The image being looked at full screen, by ref. Here rather than in the row that drew the
// thumbnail: a row regrouped underneath the reader takes its whole subtree with it.
var fullImage by remember { mutableStateOf<String?>(null) }
@@ -354,6 +364,17 @@ fun SessionScreen(
// `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
// fight over the first frame.
val listState = remember(address) { LazyListState() }
// The list's own fling path -- what a real flick decays through -- captured here so BenchRun's
// fling phase can drive `LazyListState.scroll` through exactly the `FlingBehavior` this
// screen's
// `TranscriptList` already uses by not overriding it (its `LazyColumn` takes no `flingBehavior`
// argument, so this is the same default it gets).
val flingBehavior = ScrollableDefaults.flingBehavior()
// Where BenchRun's type phase focuses before it types, and the view it toggles the keyboard on
// -- both bench-only, but cheap enough (a remembered object, a CompositionLocal read) to hold
// unconditionally rather than behind a second code path only the bench build compiles.
val composerFocus = remember { FocusRequester() }
val view = LocalView.current
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
@@ -367,12 +388,14 @@ fun SessionScreen(
// Bumped when a cold reply's parses become ready, so the flatten runs again and can split it.
var warmedTick by remember { mutableIntStateOf(0) }
val units =
remember(rows, expandedNotes, warmedTick) { transcriptUnits(rows, replies, expandedNotes) }
remember(rows, expandedNotes, warmedTick, shownWholeRows) {
transcriptUnits(rows, replies, expandedNotes, shownWholeRows)
}
// The reply that just finished streaming is the one row whose parses nobody has made: pages
// warm before their fold lands, but nothing warms live deltas. Off the composing thread, then
// the tick re-flattens, so settling never costs a whole-message parse in a frame.
LaunchedEffect(rows) {
val cold = unwarmedReplies(rows, replies)
LaunchedEffect(rows, shownWholeRows) {
val cold = unwarmedReplies(rows, replies, shownWholeRows)
if (cold.isNotEmpty()) {
warm(replies, cold)
warmedTick++
@@ -535,6 +558,20 @@ fun SessionScreen(
toggle()
}
/**
* [toggleAnchored] for a control that sits *below* the row it grows -- a capped message's "Show
* all", which is its own list item under the message it reveals.
*
* Always the top edge, with no reading of which half was touched: the control is at the bottom
* of the row by construction, and the whole point of pressing it is that the text just above it
* continues. Held from the bottom instead, the revealed lines would push everything the reader
* had been reading up off the screen and leave them at the end of the message.
*/
fun expandAnchored(key: Any, reveal: () -> Unit) = expanding {
topEdgeHeld.key = key
reveal()
}
/**
* Whether the row holding transcript position [seq] is loaded, with older history behind it.
*
@@ -1256,6 +1293,10 @@ fun SessionScreen(
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
crash = lastCrash(context),
extra = extra,
// Empty outside a BenchRun.run pass -- copyRenderReport's own reset below clears
// the
// marks along with everything else, so an ordinary copy never has any to show.
phaseFrames = FrameStats.phaseLines(context.refreshHz()),
)
context.copyToClipboard("ai-app render report", report)
// Also to the log, so a session driving the app over adb can read the same report the
@@ -1270,15 +1311,24 @@ fun SessionScreen(
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
}
val copyRenderReport = { buildAndCopyReport() }
// Bench build only: P0's scripted scroll-and-stream benchmark (BenchRun.kt), against the
// fixture session opened below instead of a real server. Null everywhere else -- see
// Bench build only: P0's scripted fling/stream/type/keyboard benchmark (BenchRun.kt), against
// the fixture session opened below instead of a real server. Null everywhere else -- see
// [SessionSettingsDialog]'s onRunBenchmark.
val runBenchmark: (() -> Unit)? =
if (BuildConfig.FIXTURE_MODE) {
{
settingsOpen = false
scope.launch {
val extra = BenchRun.run(context, scope, listState)
val extra =
BenchRun.run(
context = context,
scope = scope,
listState = listState,
flingBehavior = flingBehavior,
composerFocus = composerFocus,
setComposerText = { text -> input = atEnd(text) },
view = view,
)
buildAndCopyReport(extra)
}
}
@@ -1458,6 +1508,15 @@ fun SessionScreen(
) { unit ->
when (unit) {
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
is TranscriptUnit.ShowAll ->
ShowAllRow(unit.lines) {
// Anchored like every other control that changes a row's
// height: the reader is looking at the row this belongs to, and
// it is about to get much taller.
expandAnchored(unit.row) {
shownWholeRows = shownWholeRows + unit.row
}
}
is TranscriptUnit.PeerHead ->
PeerHeadRow(
unit.item,
@@ -1548,6 +1607,12 @@ fun SessionScreen(
::openImage,
)
},
isWhole = { it in shownWholeCalls },
onShowAll = { capped ->
toggleAnchored(row) {
shownWholeCalls = shownWholeCalls + capped
}
},
)
is TranscriptRow.Single ->
when (val item = row.item) {
@@ -1593,6 +1658,16 @@ fun SessionScreen(
::openImage,
)
},
isWhole = { part ->
Capped(item.id, part) in shownWholeCalls
},
onShowAll = { part ->
toggleAnchored(row) {
shownWholeCalls =
shownWholeCalls +
Capped(item.id, part)
}
},
)
is TranscriptItem.QuestionCard ->
QuestionRow(item, ::answerAll)
@@ -1777,7 +1852,10 @@ fun SessionScreen(
input = it
saveDraft(context, summary.id, it.text)
},
modifier = Modifier.fillMaxWidth(),
// BenchRun's type phase requests focus on this exact field
// (`composerFocus`)
// so it types through the real composer rather than a stand-in.
modifier = Modifier.fillMaxWidth().focusRequester(composerFocus),
// No longer "(+image)": the images are on screen above this, and a
// placeholder saying so said it in words beside the thing itself.
placeholder = { Text("Message") },
@@ -0,0 +1,108 @@
package com.example.aiapp
/**
* How much of a long thing the transcript draws before offering the rest behind a tap.
*
* One rule, four surfaces: a tool call's input, its output, and a user or assistant message. Kept
* in one file because four copies would eventually disagree about what "too long" is -- and because
* the Rust app answers the same question with the same numbers (`client-core`'s `text_cap.rs`, the
* other half of this). The two are deliberately identical so that a benchmark comparing the apps is
* comparing renderers rather than policies.
*
* **Lines and bytes both, whichever runs out first**, because they run out on different things: a
* diff is thousands of short lines, a minified file or a base64 blob is one enormous one, and a cap
* counting only one of them draws the whole of the other.
*
* **Cut at the head, keeping the beginning.** A tool's output is read from the top and the line
* saying what went wrong is nearly always the first; a message is read from the top for the obvious
* reason. (A path is identified by its other end -- none of these is a path.)
*/
object TextCap {
/**
* The bound on a verbatim block -- a tool call's input or its output. Short, because this text
* is a machine's and the reader is looking for one line of it.
*/
const val VERBATIM_LINES = 80
const val VERBATIM_BYTES = 4096
/**
* The bound on a message. Larger than a verbatim block's in bytes and smaller in lines: prose
* is read whole and wraps, so a screenful of it is far fewer lines than a screenful of a log,
* and cutting a reply at 80 lines would cut most long answers that nobody would call long.
*/
const val MESSAGE_LINES = 200
const val MESSAGE_BYTES = 16 * 1024
}
/** [text] cut down to a bound, with the line count of the whole of it. See [cutText]. */
data class CutText(
/** What to draw. */
val shown: String,
/**
* The line count of the **whole** text, not of [shown] -- it is what the "Show all N lines"
* offer says, and a reader deciding whether to ask for the rest wants to know how much the rest
* is.
*/
val lines: Int,
)
/**
* [text] cut to [maxLines] lines and [maxBytes] bytes, or `null` when the whole of it fits.
*
* Bytes rather than characters, so that this and the Rust half cut a multi-byte character at the
* same place. UTF-8 is what the wire carries and what `client-core` measures.
*/
fun cutText(text: String, maxLines: Int, maxBytes: Int): CutText? {
require(maxLines > 0 && maxBytes > 0) {
"a cap of nothing shows an empty block and a 'Show all' for every value there is"
}
val bytes = text.toByteArray(Charsets.UTF_8)
var byLines = -1
var seen = 0
for (i in text.indices) {
if (text[i] == '\n') {
seen++
if (seen == maxLines) {
byLines = i
break
}
}
}
val byBytes =
if (bytes.size > maxBytes) {
// Back up to a character boundary. A UTF-8 continuation byte is `10xxxxxx`; cutting on
// one would split a character in half and `String(bytes)` would draw a replacement mark
// where an em dash was.
var end = maxBytes
while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end--
String(bytes, 0, end, Charsets.UTF_8).length
} else {
-1
}
val cut =
when {
byLines >= 0 && byBytes >= 0 -> minOf(byLines, byBytes)
byLines >= 0 -> byLines
byBytes >= 0 -> byBytes
else -> return null
}
return CutText(text.take(cut), lineCount(text))
}
/**
* How many lines [text] holds, counted the way Rust's `str::lines` counts them -- a trailing
* newline ends the last line rather than starting an empty one.
*
* Said here rather than left to `lineSequence().count()`, which disagrees on exactly that case: the
* two apps have to offer "Show all N lines" with the same N for the same message, and a count that
* is one out on every text ending in a newline (which is most tool output) would show it.
*/
fun lineCount(text: String): Int =
when {
text.isEmpty() -> 0
text.endsWith("\n") -> text.count { it == '\n' }
else -> text.count { it == '\n' } + 1
}
/** What a "Show all" offer says, so the wording is one string rather than one per surface. */
fun showAllLabel(lines: Int): String = "Show all $lines lines"
@@ -1,5 +1,6 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -9,6 +10,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import org.json.JSONObject
@@ -105,33 +108,112 @@ fun parseToolInput(tool: String, input: String): ToolInput {
*
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
*
* **Capped.** An `Edit`'s `old_string` and `new_string` arrive here whole and are routinely the
* largest text on the screen, so the input is cut to [TextCap.VERBATIM_LINES] /
* [TextCap. VERBATIM_BYTES] with a "Show all" under it -- one control for both blocks, because the
* subject and the leftover fields are two halves of the same answer to "what was this call given",
* and two would make the reader ask twice. [whole] is the reader having already asked.
*/
@Composable
fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
fun ToolInputView(
tool: String,
input: String,
modifier: Modifier = Modifier,
whole: Boolean = false,
onShowAll: () -> Unit = {},
) {
val parsed = remember(tool, input) { parseToolInput(tool, input) }
if (parsed.subject == null && parsed.rest.isEmpty()) return
val subject = remember(parsed.subject, whole) { capped(parsed.subject, whole) }
val rest =
remember(parsed.rest, whole) {
capped(parsed.rest.takeIf { it.isNotEmpty() }?.joinToString("\n"), whole)
}
RawBlock(modifier) {
parsed.subject?.let { subject ->
subject.shown?.let { shown ->
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely.
Text(
// Highlighted over what is *drawn* rather than over the whole subject, so a cut
// cannot leave a span pointing past the end of the text it styles.
//
// Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it.
remember(subject, parsed.language) { highlight(subject, parsed.language) },
remember(shown, parsed.language) { highlight(shown, parsed.language) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
}
parsed.rest.forEach {
rest.shown?.let { shown ->
// Never dropped: a field left out would be claiming the tool has no other input when it
// might. Not wrapped, for the subject's reason -- Iris, 2026-09-08: "for 'raw' text
// like
// tool results I think it should not be wrapped".
Text(
it,
shown,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
softWrap = false,
modifier =
Modifier.padding(top = 2.dp)
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
)
}
// The count is the whole input's, both blocks together, because that is what the one
// control reveals.
if (subject.cut || rest.cut) {
ShowAllRow(lines = subject.lines + rest.lines, onClick = onShowAll)
}
}
}
/** One verbatim block as it will be drawn; see [capped]. */
data class CappedBlock(
/** The text to draw, or `null` when there was none to begin with. */
val shown: String?,
/** The line count of the whole of it. */
val lines: Int,
/** Whether anything was left out. */
val cut: Boolean,
)
/**
* [text] as an open card draws it: the whole of it when [whole], or [TextCap]'s worth otherwise.
*
* `null` in, `null` out, so a caller with nothing to draw reads the same three fields as one with
* something.
*/
private fun capped(text: String?, whole: Boolean): CappedBlock {
if (text == null) return CappedBlock(null, 0, false)
val cut = if (whole) null else cutText(text, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
return when (cut) {
null -> CappedBlock(text, lineCount(text), false)
else -> CappedBlock(cut.shown, cut.lines, true)
}
}
/**
* The "Show all N lines" under a capped block.
*
* It says the count rather than "more" because the reader is deciding whether to ask for it: "Show
* all 4,000 lines" and "Show all 12 lines" are different decisions, and "more" tells them apart not
* at all.
*/
@Composable
fun ShowAllRow(lines: Int, onClick: () -> Unit) {
val label = showAllLabel(lines)
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier.padding(top = 4.dp).clickable(onClick = onClick).semantics {
contentDescription = label
},
)
}
@@ -2,6 +2,7 @@ package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.Card
@@ -163,6 +165,9 @@ fun ToolGroup(
onToolToggle: (String) -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit,
/** Which capped blocks the reader has asked to see whole; see [Capped]. */
isWhole: (Capped) -> Boolean = { false },
onShowAll: (Capped) -> Unit = {},
) {
val heading = "Called ${group.calls.size} tools"
if (!expanded) {
@@ -202,6 +207,8 @@ fun ToolGroup(
onToggle = { onToolToggle(call.id) },
onAnswer = onAnswer,
image = image,
isWhole = { part -> isWhole(Capped(call.id, part)) },
onShowAll = { part -> onShowAll(Capped(call.id, part)) },
shape = connectedShape(index, group.calls.size),
)
}
@@ -272,6 +279,21 @@ private val GROUP_INSET = 4.dp
/** Enough to read the join as a join rather than as one tall card. */
private val GROUP_GAP = 2.dp
/**
* Which half of an open card a cap and its "Show all" belong to.
*
* The two are capped and revealed independently: opening the whole of a call's input says nothing
* about wanting the whole of its output, and one control revealing both would make the card jump by
* the sum of two things when it was asked about one.
*/
enum class ToolPart {
INPUT,
OUTPUT,
}
/** One capped thing on the screen that the reader may ask to see whole. */
data class Capped(val call: String, val part: ToolPart)
/**
* One tool call.
*
@@ -291,6 +313,11 @@ fun ToolCard(
onToggle: () -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit = {},
/**
* Whether the reader has asked for the whole of this call's input or output; see [ToolPart].
*/
isWhole: (ToolPart) -> Boolean = { false },
onShowAll: (ToolPart) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape,
) {
@@ -353,11 +380,28 @@ fun ToolCard(
// something answerable; dumping the same JSON above them would be the decision
// stated twice, once unreadably.
if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
ToolInputView(
tool.tool,
tool.input,
Modifier.padding(top = 4.dp),
whole = isWhole(ToolPart.INPUT),
onShowAll = { onShowAll(ToolPart.INPUT) },
)
}
if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall)
// Capped like the input, and revealed separately from it: a reader who wants
// the whole of a 900-line `new_string` rarely also wants the whole of the build
// log underneath it.
val wholeOutput = isWhole(ToolPart.OUTPUT)
val cut =
remember(tool.output, wholeOutput) {
if (wholeOutput) null
else
cutText(tool.output, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
}
val shown = cut?.shown ?: tool.output
// What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is
// prose, and a proportional font silently destroys the alignment that carried
@@ -367,13 +411,23 @@ fun ToolCard(
// often the whole of what a diff or a test run is saying. Remembered against
// the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() }
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
val styled = remember(shown, palette) { ansiStyled(shown, palette) }
RawBlock(Modifier.padding(top = 2.dp)) {
// Not wrapped, and panning sideways instead -- Iris, 2026-09-08: "for 'raw'
// text like tool results I think it should not be wrapped". Wrapping a
// column-aligned log is what destroys the alignment that carried its
// meaning, one line at a time and only on the long lines.
Text(
styled,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
modifier =
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
if (cut != null) {
ShowAllRow(cut.lines) { onShowAll(ToolPart.OUTPUT) }
}
}
}
}
@@ -5,7 +5,7 @@ package com.example.aiapp
*
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
* a subagent -- see docs/SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
*/
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
@@ -130,6 +130,26 @@ sealed class TranscriptUnit {
get() = "u$seq:$ordinal"
}
/**
* The "Show all N lines" under a message drawn only as far as [TextCap.MESSAGE_LINES].
*
* Its own unit rather than something inside the row above it, because the row above it is a
* *bounded* item now and this is what says so -- and because a control that lives inside the
* thing it reveals moves the moment it is pressed.
*/
data class ShowAll(
override val seq: Long,
override val ordinal: Int,
/** The row this belongs to; what goes into the set of rows shown whole. */
val row: Any,
/** The line count of the whole message, which is what the offer says. */
val lines: Int,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "s$row"
}
/** One memory note of a settled reply; see [MemoryNote]. */
data class Memory(
override val seq: Long,
@@ -142,6 +162,36 @@ sealed class TranscriptUnit {
}
}
/**
* A message row cut to [TextCap]'s worth of itself, and the line count of the whole of it.
*
* The cut happens **before** the flatten below decides how to draw the row, so everything after it
* -- pieces, chunks, warming -- sees a shorter message and needs to know nothing about caps. The
* shortened row keeps its key and its seq, so the list's identity and every saved scroll anchor are
* untouched by a reader opening or closing one.
*
* A reply still arriving is never capped: it grows by deltas, and a row that stopped growing at two
* hundred lines while the model was plainly still writing would read as the stream having died.
* `iris`'s `row::build_row` states the same rule for the same reason.
*/
private fun capRow(row: TranscriptRow, shownWhole: Set<Any>): Pair<TranscriptRow, Int?> {
val item = (row as? TranscriptRow.Single)?.item ?: return row to null
if (row.key in shownWhole) return row to null
val cut =
when {
item is TranscriptItem.UserMsg ->
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
it to TranscriptRow.Single(item.copy(text = it.shown))
}
item is TranscriptItem.AssistantMsg && item.settled ->
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
it to TranscriptRow.Single(item.copy(text = it.shown))
}
else -> null
} ?: return row to null
return cut.second to cut.first.lines
}
/**
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start.
@@ -161,11 +211,14 @@ fun transcriptUnits(
rows: List<TranscriptRow>,
replies: ParsedReplies,
openNotes: Set<Long>,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptUnit> {
val started = System.nanoTime()
val units = ArrayList<TranscriptUnit>(rows.size)
rows.forEachIndexed { index, row ->
rows.forEachIndexed { index, whole ->
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
val (row, hidden) = capRow(whole, shownWhole)
val rowStart = units.size
val item = (row as? TranscriptRow.Single)?.item
if (item is TranscriptItem.PeerNote) {
val open = item.seq in openNotes
@@ -240,6 +293,16 @@ fun transcriptUnits(
} else {
units += TranscriptUnit.Whole(row, rowGap)
}
if (hidden != null) {
units +=
TranscriptUnit.ShowAll(
row.startSeq,
units.size - rowStart,
row.key,
hidden,
BLOCK_SPACING,
)
}
}
units.reverse()
reportDuplicateKeys(units)
@@ -266,11 +329,18 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
* ready parses.
*/
fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> =
rows.mapIndexedNotNull { index, row ->
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
}
fun unwarmedReplies(
rows: List<TranscriptRow>,
replies: ParsedReplies,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptItem> = rows.mapIndexedNotNull { index, whole ->
// The *capped* row's text, since that is what the flatten will draw and so what has to be
// ready: a capped row draws its head, which is a different string from the message and so a
// different cache entry.
val row = capRow(whole, shownWhole).first
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
}
/**
* Above this many characters, a user message is drawn in slices rather than as one bubble.
@@ -375,6 +445,7 @@ private val TranscriptUnit?.kind: String
is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice"
is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.ShowAll -> "show all"
is TranscriptUnit.Whole ->
when (val row = row) {
is TranscriptRow.Tools -> "tool group"
@@ -0,0 +1,63 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The cap rule, which this app and the Rust one have to answer identically -- these mirror
* `client-core/src/text_cap.rs`'s own tests case for case, because "Show all 4,000 lines" appearing
* in one app and "Show all 4,001" in the other is exactly the kind of difference a benchmark
* comparing the two would report as a rendering difference.
*/
class TextCapTest {
@Test
fun textUnderBothBoundsIsNotCut() {
assertNull(cutText("one\ntwo\nthree", 80, 4096))
}
@Test
fun theLineBoundCutsAtALineBoundary() {
val cut = cutText("a\nb\nc\nd\n", 2, 4096)!!
assertEquals("a\nb", cut.shown)
assertEquals(4, cut.lines, "the count is the whole text's, not the shown part's")
}
/**
* The half the line bound cannot catch: one enormous line, which is what a minified file is.
*/
@Test
fun theByteBoundCutsOneLongLine() {
val cut = cutText("x".repeat(5000), 80, 4096)!!
assertEquals(4096, cut.shown.length)
assertEquals(1, cut.lines)
}
@Test
fun theTighterOfTheTwoBoundsWins() {
val text = "aaaa\n".repeat(100)
assertEquals(100, cutText(text, 80, 100)!!.shown.length)
assertEquals("aaaa\naaaa\naaaa\naaaa", cutText(text, 4, 4096)!!.shown)
}
/**
* A cut landing inside a multi-byte character has to back up to the boundary. The Rust half
* measures in UTF-8 bytes, so this one does too -- counting UTF-16 characters instead would cut
* the same text at a different place in every message with an em dash in it.
*/
@Test
fun aCutInsideAMultibyteCharacterBacksUpToTheBoundary() {
val cut = cutText("é".repeat(100), 80, 11)!!
assertEquals("é".repeat(5), cut.shown, "11 bytes lands mid-character; 10 is the cut")
}
/** A trailing newline ends the last line rather than starting an empty one; see [lineCount]. */
@Test
fun lineCountMatchesRustsStrLines() {
assertEquals(0, lineCount(""))
assertEquals(1, lineCount("a"))
assertEquals(1, lineCount("a\n"))
assertEquals(2, lineCount("a\nb"))
assertEquals(2, lineCount("a\nb\n"))
}
}
+21 -1
View File
@@ -12,17 +12,37 @@ script and README, because the Compose `bench` build type points its own asset s
at `assets/` (`app/androidApp/build.gradle.kts`'s `sourceSets { getByName("bench") }`), and a Python
script and a markdown file have no business inside an APK:
- `transcript.jsonl` -- 3,601 events. The first 3,200 (`BACKLOG_COUNT`) are the scrolled-back
- `transcript.jsonl` -- 3,603 events. The first 3,202 (`BACKLOG_COUNT`) are the scrolled-back
history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output,
assistant replies built from headings, bold/italic/inline code, a link, fenced code blocks that
rotate through rust/kotlin/python/sh/json/toml, a markdown table, two embedded images, and
periodic `usageDelta`/`compacted` events. The remaining 400 (`STREAM_COUNT`) are not part of the
opening window -- both bench harnesses replay them at a fixed rate (20/s) through the same live
fold path a real SSE reply arrives on, which is P0's "streaming phase."
**The streamed reply has a blank line every few deltas** (2026-09-09), so the markdown block a
delta lands in stays the size a real reply's blocks are -- 53 blocks, longest 502 characters,
against a measured p50 of 147 and a largest-ever 1,580 over 7,706 blocks of real assistant
messages. It used to be one run-on 14,888-character block, and since a row re-shapes the block a
delta lands in, every delta re-shaped all of it: quadratic in the reply's length, and 9.5ms of
frame time on a phone spent on a shape that does not occur. docs/RUST.md's "Incremental text"
has the measurements.
**The run-on message is kept**, as the first two events of the backlog: 14,824 characters in a
single block, just under `text_cap`'s 16 KiB `MESSAGE_BYTES` so it draws in full rather than
behind a "Show all". It is deliberately *not* streamed -- the repeated-reshape pathology needs a
growing block, and that lives in `app-rust/tests/frame_profile.rs` where it can be iterated on
in a second rather than in a two-minute phone run. It is emitted with the random state saved and
restored around it, so adding it left every other backlog event byte-identical; that is what
keeps `phone_screen.rs`'s recorded gestures landing on the content they were recorded against.
- `bench1.png`, `bench2.png` -- tiny (8x8) flat-colour PNGs, base64-free on disk but served the
same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two
`"type":"image"` events in the transcript.
`BACKLOG_COUNT` lives in three places and moves in all of them or none: here, `app-rust/src/ui/
fixture.rs` and `BenchFixture.kt`. The split is by line index, so a stale copy makes that app open
a different half of the file.
Regenerate after changing the shape (a new event type, a different backlog/stream split) with
`./generate.py`, and commit the result -- it is checked in rather than generated at build time so
both apps' bench builds embed the identical bytes without needing this script at build time.
File diff suppressed because it is too large. Load diff
+66 -4
View File
@@ -26,8 +26,40 @@ import zlib
from pathlib import Path
SEED = 20260905
BACKLOG_COUNT = 3200
# The stress message below, two events, added to the 3,200 the backlog used to be. Both apps
# hardcode this to split the file by line index (`fixture.rs`'s `BACKLOG_COUNT`,
# `BenchFixture.kt`'s), so it moves in three places or none.
STRESS_EVENTS = 2
BACKLOG_COUNT = 3200 + STRESS_EVENTS
STREAM_COUNT = 400
# How many deltas fold into one markdown block of the streamed reply.
#
# **The blank line between them is the point** (2026-09-09). Until this run the streaming tail
# appended `paragraph(5) + " "` STREAM_COUNT times with no blank line anywhere, so all 400 deltas
# folded into a *single* 14,888-character block -- and a row re-shapes the block a delta lands in
# (`RowBlocks::apply_delta`), so every delta re-shaped the whole thing. That is quadratic in the
# reply's length, and it put 9.5ms of frame time on Iris's phone into a shape that does not occur:
# measured over 7,706 top-level blocks from 3,675 real assistant messages, block length is p50 147
# characters, p90 449, p99 836, largest 1,580, nothing above 4,000. `paragraph(5)` is ~35
# characters, so 4-12 of them per block lands in that range.
#
# The run-on version is kept, as STRESS_CHARS below -- Iris, 2026-09-09: "let's switch to new
# lines for the test, and also let's keep the single line around for stress".
DELTAS_PER_BLOCK = (4, 12)
# One deliberately pathological message in the backlog: a single markdown block with no blank line
# in it, the shape the streaming tail used to have. Sized just under `text_cap`'s MESSAGE_BYTES
# (16 KiB) so it is drawn in full rather than behind a "Show all" -- which makes it a genuine
# stress for one-shot shaping and puts a row right on the cap boundary, where nothing else is.
#
# It is *not* streamed: the repeated-reshape pathology needs a growing block, and that lives in
# `app-rust/tests/frame_profile.rs`'s `what_reshaping_a_growing_message_costs`, where it can be
# iterated on in a second rather than in a two-minute phone run. Note that the cap would not save
# a real one anyway -- `row::build_row`'s `cap` is deliberately `false` for the live tail, because
# a row that grew while capped would appear to stop growing, so a streamed block's shaping cost
# has no ceiling.
STRESS_CHARS = 14_800
HERE = Path(__file__).resolve().parent / "assets"
random.seed(SEED)
@@ -122,6 +154,26 @@ def main():
emit("status", state="running")
emit("settings", model="bench-model", permissionMode="auto")
# The stress message, emitted first and with the random state put back afterwards, so that
# adding it is **purely additive**: every turn the loop below generates is byte-identical to
# what it generated before this existed, and only the streaming tail changed. That matters
# because the fixture's opening view is what `phone_screen.rs`'s recorded gestures press --
# `a_long_press_and_drag_selects_text` drives a real recording at (300, 1000) and fails the
# moment different content lands under it. `ts` still shifts by the two draws, which nothing
# reads.
rng_state = random.getstate()
emit(
"userMessage",
text="And the pathological one: a reply with no paragraph break in it.",
id=None,
attachments=[],
)
run_on = ""
while len(run_on) < STRESS_CHARS:
run_on += paragraph(5) + " "
emit("assistantText", delta=run_on.rstrip())
random.setstate(rng_state)
image_refs = []
turn = 0
while seq <= BACKLOG_COUNT:
@@ -167,11 +219,21 @@ def main():
trigger="auto",
)
# The streaming-phase tail: one long reply, built entirely from text deltas, the shape a
# bench harness replays at a fixed events/sec through the live fold path.
# The streaming-phase tail: one reply built entirely from text deltas, the shape a bench
# harness replays at a fixed events/sec through the live fold path -- with a blank line every
# few deltas, so the block a delta lands in stays the size a real reply's blocks are. See
# DELTAS_PER_BLOCK for what the alternative measured.
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
until_break = random.randint(*DELTAS_PER_BLOCK)
while seq <= BACKLOG_COUNT + STREAM_COUNT:
emit("assistantText", delta=paragraph(5) + " ")
until_break -= 1
if until_break <= 0:
# The break rides on the last delta of the block rather than being an event of its
# own, so STREAM_COUNT still counts deltas a reader sees text arrive from.
emit("assistantText", delta=paragraph(5) + "\n\n")
until_break = random.randint(*DELTAS_PER_BLOCK)
else:
emit("assistantText", delta=paragraph(5) + " ")
emit("status", state="idle")
(HERE / "transcript.jsonl").write_text("\n".join(lines) + "\n")
+5 -5
View File
@@ -1,11 +1,11 @@
plugins { alias(libs.plugins.androidApplication) }
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
// into Rust (`../../android-shell`). Deliberately its own module rather
// into Rust (`../../app-rust`, the `shell` feature). Deliberately its own module rather
// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines
// of working Compose UI this experiment does not touch, and the two can be
// installed side by side on the same development device (see
// `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme
// `settings.SCHEME`'s doc in `app-rust/src/shell` for why the deep-link scheme
// and Keystore alias are not the production app's). No Compose plugin, no
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain
// Java, and the CA constant below is generated as Java too.
@@ -13,7 +13,7 @@ plugins { alias(libs.plugins.androidApplication) }
// The CA this build pins is baked in the same way `androidApp`'s does --
// see that module's `build.gradle.kts` comment for the reasoning (the
// trust boundary follows the machine that builds, never a pasted copy).
// `PinnedCa.java`'s package must match `android-shell`'s
// `PinnedCa.java`'s package must match `app-rust/src/shell`'s
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
val pinnedCaPath: String =
System.getenv("AI_APP_CA")
@@ -150,12 +150,12 @@ androidComponents {
dependencies {
// The Keystore-sealed enrollment (ServerStore/ServerSettings) --
// android-shell's settings.rs calls into this Kotlin class directly
// the shell bridge's settings.rs calls into this Kotlin class directly
// over JNI rather than re-sealing the token in Rust; see that file's
// module doc.
implementation(project(":link"))
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
// ServiceCompat -- android-shell's notify.rs calls these classes over
// ServiceCompat -- the shell bridge's notify.rs calls these classes over
// JNI so the pre-26 fallback behaviour (no channels) lives once, in
// the library that already has it, rather than being re-derived as a
// set of Build.VERSION.SDK_INT branches in Rust.
@@ -17,7 +17,7 @@ import android.widget.Toast;
*/
public class MainActivity extends Activity {
static {
System.loadLibrary("android_shell");
System.loadLibrary("ai_app");
}
@Override
@@ -14,7 +14,7 @@ import android.os.IBinder;
*/
public class NotificationService extends Service {
static {
System.loadLibrary("android_shell");
System.loadLibrary("ai_app");
}
@Override
Loaded 100 of 318 files, more files were not shown because too many files have changed in this diff. Show more