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
311 changed files with 31734 additions and 36672 deletions

No files matched your search

+7 -3
View File
@@ -1,6 +1,10 @@
# xtask convention (https://github.com/matklad/cargo-xtask), without folding # xtask convention (https://github.com/matklad/cargo-xtask), without folding
# every crate in this repo into one workspace -- they are deliberately # every crate in this repo into one workspace -- they are deliberately
# independent (see run-tests.sh, which cds into each). `cargo xtask apk` # independent (see scripts/run-tests.sh, which cds into each).
# from the repo root runs xtask/src/main.rs directly. #
# `cargo xtask apk` **from the repo root** runs scripts/xtask/src/main.rs.
# The manifest path is relative to the working directory cargo is run from,
# so the root is where it works; this file is found from any directory
# inside the checkout, but the path inside it is not.
[alias] [alias]
xtask = "run --quiet --manifest-path xtask/Cargo.toml --" xtask = "run --quiet --manifest-path scripts/xtask/Cargo.toml --"
+5 -3
View File
@@ -64,9 +64,11 @@ components: [
// defaults to this checkout's root, which both the `cargo xtask` // defaults to this checkout's root, which both the `cargo xtask`
// alias (`.cargo/config.toml`, resolved relative to the working // alias (`.cargo/config.toml`, resolved relative to the working
// directory cargo is run from) and `cargo xtask apk`'s own publishing // directory cargo is run from) and `cargo xtask apk`'s own publishing
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's // step (`scripts/build/outputs/apk/<mode>/*.apk`, matching
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc) // discover.rs's `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's
// both need. // module doc) both need. The publish directory is `scripts/build`
// rather than `scripts/xtask/build` for exactly that reason: the
// pattern is one directory deep, and the tool moved two on 2026-09-09.
Apk( Apk(
name: "shell", name: "shell",
modes: ["release", "debug"], modes: ["release", "debug"],
+6 -9
View File
@@ -9,8 +9,7 @@ local.properties
.DS_Store .DS_Store
server/target/ server/target/
event-model/target/ event-model/target/
client-core/target/ app-rust/target/
android-shell/target/
# E3's native library, built by cargo-ndk straight into the Gradle module # E3's native library, built by cargo-ndk straight into the Gradle module
# (RUST.md) -- an artifact, like server/target/ above, not source. # (RUST.md) -- an artifact, like server/target/ above, not source.
@@ -32,11 +31,9 @@ sessions/
# iris, the in-house UI library, is vendored at iris/ and built by cargo. # iris, the in-house UI library, is vendored at iris/ and built by cargo.
iris/target/ iris/target/
iris/android-app/target/
# E5's packaging xtask (RUST.md). `build/` above already covers # The packaging xtask and the GPU rigs, both under scripts/. `build/`
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc). # above already covers scripts/build/outputs/apk, where `cargo xtask apk`
# The repo root has no Cargo workspace, so this is xtask's own # publishes for Dev Updater to find.
# intermediate working files (target/xtask/apk/...), not a shared one. scripts/xtask/target/
xtask/target/ scripts/rigs/gpu-probe/target/
/target/
+221 -40
View File
@@ -20,19 +20,26 @@ is a new driver — never a session-type branch in shared code (routes,
transcript, app screens). transcript, app screens).
The second one, for the Rust port on the `rustify` branch: **the phone app 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, and a planned desktop app share almost all of their code.** Screens,
folding, paging, config and the network client live in the shared crates widgets, folding, paging, config and the network client live in
(`iris`, `client-core`, `transcript-ui`, `tabs-ui`); `android-app` and `app-rust/`'s `client` and `ui` modules, drawn with `iris`; `src/android`
`desktop-app` are thin entry points that own only what the platform forces and `src/desktop` are thin entry points that own only what the platform
(JNI and the IME on one side, winit and argv on the other). The two 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 *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 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, 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 type) are one implementation with no per-platform copy. Anything that could
work on both goes in a shared crate the first time it is written, and a work on both goes in `ui` the first time it is written, and a platform
platform crate growing a widget or a colour is a defect to move, not a module growing a widget or a colour is a defect to move, not a convenience
convenience to keep. Iris said this on 2026-09-07; docs/RUST.md carries the to keep. Iris said this on 2026-09-07; docs/RUST.md carries the details.
details.
The third, from Iris on 2026-09-08: **`iris/` is the UI framework and
nothing else.** Nothing in it may know about a session, a transcript, a
setup or a server; anything that does belongs in `app-rust/`, and the
dependency runs one way only. The port's project code is **one crate**
(`ai-app`) rather than the six it was scattered across — see docs/RUST.md's
"One app crate" for what forced each of the splits that were removed and
the two that remain.
## Layout ## Layout
@@ -44,11 +51,46 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc - `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
comment is the HTTP table and the surface's source of truth. comment is the HTTP table and the surface's source of truth.
- `event-model/` — the wire shape `server/` and `app-rust/` both depend on,
which is the whole reason it is a crate of its own rather than part of
either: it is the contract between them, so the two agree by construction.
- `app-rust/` — the Rust app, one crate (`ai-app`) with three faces. `src/
client` is everything with no UI in it (the REST and SSE clients, the
transcript cache and fold, the highlighter, the ANSI parser, config and
the enrolment link); `src/ui` is the screens as iris widget trees;
`src/desktop` + `src/bin_desktop.rs` is the winit binary; `src/android`
is the `android-view` entry point and `android-project/` its Gradle app;
`src/shell` is the separate JNI bridge the Kotlin `app/shellApp` calls.
Features pick which face a build is: `screens` (default) for anything
that draws, `shell` for the Compose app's bridge, `bench` for P0's
fixture build. See its `Cargo.toml` header.
- `iris/` — the UI framework, and **only** the UI framework: `core`,
`macro`, the `iris` crate itself, `tabs-ui` (its own demo widget tree)
and `rig-input`. It must not mention anything this product is about.
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". - `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs `AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE (sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
the Keystore-sealed token. the Keystore-sealed token.
- `scripts/` — everything at the root that was neither a program nor a
document: the three repo-wide shell scripts (`run-tests.sh`,
`test-wg-tunnel.sh`, `wg-setup-host.sh`), `rigs/` (the `gpu-probe` and
`virtgpu-probe` device probes, and `ui-profile`'s two layer-1
profiling rigs), and `xtask/`. **A project's own scripts
stay with the project** — `app/*.sh`, `app-rust/*.sh`, `iris/*.sh` and
`server/enroll-link.sh` did not move (Iris, 2026-09-09: "I only meant
top level sh files").
`scripts/xtask/` is the [cargo-xtask](https://github.com/matklad/cargo-xtask)
convention: an ordinary Rust binary that does build work a shell script
would otherwise do, run as `cargo xtask apk` **from the repo root**
(`.cargo/config.toml`'s alias, whose `--manifest-path` is relative to
the working directory). It packages `app/shellApp` without Gradle
driving it — `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` →
`apksigner` — and publishes to `scripts/build/outputs/apk/<mode>/`,
which is where Dev Updater looks. There is deliberately **no `target/`
at the repo root** any more: there is no workspace there, and what used
to be one was only xtask's own scratch space, now in
`scripts/xtask/target/`.
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA - `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0 and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
binding and the certificate's SANs (`netif`), owner-only files (`private`), binding and the certificate's SANs (`netif`), owner-only files (`private`),
@@ -66,18 +108,53 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
the opening and stream effects in `SessionScreen.kt`. the opening and stream effects in `SessionScreen.kt`.
- `docs/TODO.md` — the working list. - `docs/TODO.md` — the working list.
- `docs/SUBAGENTS.md` — a session's subagents: the wire shape, the
phone's view, and the choices behind the shape.
- `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify` - `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
branch of the `ai-app-2` clone): what has to be reproduced, the branch of the `ai-app-2` clone): what has to be reproduced, the
framework decision, and the ordered experiments with their pass framework decision, and the ordered experiments with their pass
conditions. Read it before touching anything under that branch. conditions. Read it before touching anything under that branch.
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`, - `docs/IRIS_TODO.md`, `docs/LAYOUT.md`, `docs/TEXTURES.md`,
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's `docs/CLIENT_CORE.md` — iris's open working list, its layout/render
own public API log, working list, decisions log, layout/render design, design, its texture-atlas design, and the design of `app-rust`'s
and texture-atlas design, and the client-core crate's design, `client` module, respectively.
respectively.
**These documents are pruned as the work lands, not appended to
forever** (Iris, 2026-09-08: *"remove everything that's already done and
decided… many with checkboxes already ticked off that just fill up
context"*). A ticked box, a finished experiment and a completed review
are deleted once carried out; `IRIS_TODO.md` holds only open items, and
a finished document is removed rather than archived in place. What
survives is what cannot be cheaply re-derived — measurements, dead ends
and failed hypotheses, invariants and their reasons, and the design of
what exists now rather than the route to it.
**There is no decisions log and no design log, and one should not be
started.** `docs/DECISIONS.md` and `docs/IRIS.md` were deleted on
2026-09-09 at Iris's instruction: *"I've decided to instead make
decisions when planning with agents rather than after they do things,
and they're both too long for me to wanna read, + don't cover all the
decisions I'll wanna make about the code anyways. I'll just naturally
run into things for now."* So raise a choice **while planning it with
her**, when the direction is still cheap to change; otherwise decide it,
put the reasoning at the code it governs, and carry on. TODO lists are
still wanted — a list of open work is useful, a list of finished work
is not.
- `docs/SCROLL.md` — how anything in iris scrolls: one
`ScrollController` holds the position, the gesture, the fling and the
pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own
one each through the `Scrollable` trait. Read it before touching
`scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that
pans, flings or lays out a long list.
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as - `.dev-updater.ron` — what Dev Updater builds here: the server (run as
`service: Managed(…)`, supervised by Dev Updater's own implementation `service: Managed(…)`, supervised by Dev Updater's own implementation
rather than a script kept here) and the APK, in parallel. It points at rather than a script kept here), the Compose app and the shell APK, in
parallel. **It does not publish either benchmark APK.** Phone benchmark
builds live in the separate `~/repos/ai-app-bench` repository: build here,
copy the verified artifact to that repo's `compose/` or `iris/` Gradle-shaped
path, then commit and push that repo. Dev Updater pulls the committed APK
from there; pushing `ai-app-2` alone cannot update its benchmark card. This
file points at
`resources.ron`, which is *ours* rather than Dev Updater's — it names `resources.ron`, which is *ours* rather than Dev Updater's — it names
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can `~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
offer them. Note what deleting the config directory takes with it: the CA offer them. Note what deleting the config directory takes with it: the CA
@@ -100,12 +177,33 @@ the **Mono** face, where every glyph is one em square, which is what makes
two icon buttons the same width without either being given one — and why two icon buttons the same width without either being given one — and why
`GLYPH_SIZE` is smaller than it looks like it should be. `GLYPH_SIZE` is smaller than it looks like it should be.
**The Rust app does the same, from its own subset**:
`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`,
with the codepoints named in `iris/core/src/icon.rs` and drawn as text
with `Family::Icons`. Same rule about the two lists agreeing (there is a
test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material
Design family so an icon means the same thing in both apps. Its subset is
separate rather than shared because subsetting only what one app draws is
the point. This is the **only** font iris bundles — body and monospace
text come from the platform (decided 2026-09-07), and an icon
is the opposite case: a small closed set of codepoints no system font is
guaranteed to have.
## Checking your work ## Checking your work
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from - **Commit completed work.** Once a coherent piece of work has passed its
`server/`), plus `cargo clippy --all-targets` and `cargo fmt`. The build relevant checks and has no known major issue or unresolved design decision,
stays warning-clean and rustfmt-clean at the defaults — there is no commit it rather than leaving it in the worktree. Keep independently
`rustfmt.toml` and there should not be one. completed slices in separate commits.
- **Rust**: `./scripts/run-tests.sh` from the repo root runs `event-model`,
`server/` and `app-rust/`; `cd iris && cargo test` runs the framework's
own suite, which is slower and not about this product. Each workspace
also gets `cargo clippy --all-targets` and `cargo fmt`. The build stays
warning-clean and rustfmt-clean at the defaults — there is no
`rustfmt.toml` and there should not be one. `app-rust/`, `iris/`, and the
UI profiling rig use the rolling nightly channel through per-directory
`rust-toolchain.toml` files; `server/` and `event-model/` are stable.
- **App**: from `app/`, - **App**: from `app/`,
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
:androidApp:compileDebugKotlin :androidApp:lintDebug :androidApp:compileDebugKotlin :androidApp:lintDebug
@@ -166,7 +264,7 @@ two icon buttons the same width without either being given one — and why
whether it was continued or reset (`stream backlog:`). That is the only whether it was continued or reset (`stream backlog:`). That is the only
place "how far had this phone fallen behind" is answerable — the app sees a place "how far had this phone fallen behind" is answerable — the app sees a
window arrive and cannot tell. window arrive and cannot tell.
- **`./test-wg-tunnel.sh up|test|down`** builds a real tunnel between two - **`./scripts/test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
network namespaces inside one machine and drives the server through it — a network namespaces inside one machine and drives the server through it — a
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
involved. That is how to verify the wg0-only posture. involved. That is how to verify the wg0-only posture.
@@ -176,7 +274,7 @@ two icon buttons the same width without either being given one — and why
Each exists because something was invisible without it. Each exists because something was invisible without it.
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md - **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
and DECISIONS.md's 2026-09-05 entries), the phone benchmark gate Iris and the 2026-09-05 decisions), the phone benchmark gate Iris
asked for before porting continues: a deterministic, checked-in synthetic asked for before porting continues: a deterministic, checked-in synthetic
transcript (`app/bench-fixture/generate.py`, never a real one) that both transcript (`app/bench-fixture/generate.py`, never a real one) that both
this app and iris open with no server, so a frame-time comparison this app and iris open with no server, so a frame-time comparison
@@ -276,27 +374,113 @@ Each exists because something was invisible without it.
framework, from `atrace` text output with no trace processor needed. It is framework, from `atrace` text output with no trace processor needed. It is
how the cost of a layout node per link was attributed to the framework how the cost of a layout node per link was attributed to the framework
rather than guessed at. rather than guessed at.
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features - **`app-rust/build-apk.sh [debug|release] [--abi ...] [--features
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK ...]`** builds the Rust app's cdylib (`cargo ndk` from `app-rust/`,
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and straight into `android-project/app/src/main/jniLibs/`) and its APK
**`iris/android-app/run-bench.sh [--apk PATH]`** installs it on this (Gradle, from `android-project/`) in one step and verifies the result
checkout's own emulator, taps "Run benchmark" by label, and prints the (`aapt2`/`apksigner`), and **`app-rust/run-bench.sh [--apk PATH]`**
report -- written so the P0 build/install/tap/read-report cycle stops installs it on this checkout's own emulator, taps "Run benchmark" by
being retyped by hand each time (docs/RUST.md's P0 box). label, and prints the report -- written so the P0
build/install/tap/read-report cycle stops being retyped by hand each
time (docs/RUST.md's P0 box). It passes `--no-default-features`, so
`--features` alone decides what is in the `.so`; that is what keeps the
1.9 MB bench fixture out of a build that did not ask for `bench`. A phone
build is published only by replacing
`~/repos/ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`
and pushing the **bench repository**, not this checkout.
- **iris's three test layers** (docs/RUST.md's "Three test layers" has - **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 the commands and what each cannot answer): test at the cheapest one
that can answer the question. `cargo test -p transcript-fixture` runs that can answer the question. `cd app-rust && cargo test` runs the real
the real transcript screen over the bench fixture with **no window, no transcript screen over the bench fixture with **no window, no
compositor and no GPU** (`iris::harness`), on a clock the test owns and 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 a gesture replayed from a `t_ms action x y` file under
`iris/transcript-fixture/touch/` -- which is how the batched 120Hz `app-rust/touch/` -- which is how the batched 120Hz
flick a finger actually makes is testable at all, since a `ui-trace` 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 swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone
--shot …` opens the same screen in a window at the phone's own size and --dir ../app-rust --shot …` opens the same screen in a window at the
density for looking at, and `--replay FILE` drives the same recording phone's own size and density for looking at, and `--replay FILE` drives
into it. The emulator is for JNI, the IME, insets, the surface 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 -- lifecycle and one verification run before a build goes to the phone --
not for iterating on layout. not for iterating on layout.
- **`scripts/rigs/ui-profile/`** holds the two layer-1 profiling rigs, in
a crate of their own so a rig's dependencies stay out of the app's
(Iris, 2026-09-09: *"Rigs should probably all be in their own crate so
dependencies and such don't get mixed"*). Run either from that
directory; both are `#[ignore]`d and assertion-free, so `run-tests.sh`
neither runs them nor can fail on them, and both need **release or the
numbers mean nothing**.
- **`tests/frame_profile.rs`** is what a frame costs on the CPU,
at layer 1 -- `cargo test --release --test frame_profile -- --ignored
--nocapture`. Two runs: a fling over the bench
fixture eight times out and back, and a reply streaming into it one
event at a time. Text shaping dominates, which is why the profile is
meaningless unoptimised. It cannot answer anything about the GPU, the
swapchain or the phone's own clock.
What it established on 2026-09-09, worth not re-deriving. A **fling**
is not CPU-bound: only about one frame in six lays anything out (the
rest are moved on the GPU through `move_offsets`), and the
multi-millisecond spikes are all in the *first* pass over a stretch of
transcript -- every later pass over the same rows is p99 0.26ms. A
**streamed event** is, and it is not where it looks: folding the event
is 0.35ms and applying the diff to the widget tree is 0.41ms, while the
*frame* is 3.86ms here and 9.5ms on Iris's phone. (The fold was the
hypothesis, from `foldEvent`'s Compose lesson under "Things that have
bitten"; measuring it is what ruled it out.) That frame is one
`TextBuffer::shape` of the block a delta landed in, and **the fixture's
is 14,888 characters in a single block** -- against a largest-ever
1,580 across 7,706 blocks of real replies. So the stream phase's number
is a property of the fixture, not of streaming; docs/RUST.md's
"Incremental text" has the measurements and why parley cannot help.
The last two runs (`where_a_streamed_deltas_cost_is`,
`what_the_fixture_streams`) exist to keep that answerable: what a delta
costs to re-split and re-compare, and what the fixture actually
streams.
- **`tests/arena_churn.rs`** is what a frame costs to *upload* -- the half
of a frame layer 1 builds and never performs, and so the half
`frame_profile.rs` cannot see at all. It prints three numbers per GPU
array per frame, and the point of the rig is that no two of them alone
are honest: **floor** (entries whose bytes actually differ, found by
diffing), **uploaded** (what iris really writes, read from the same
`Dirty` sets `UiRenderNode::update` consumes), and **whole** (what the
old code wrote whenever anything changed). A gap between the first two
is over-marking; one was 122x and invisible until both were printed
side by side.
What it established on 2026-09-09, and what the three optimisations it
drove were. Uploading the whole arena on any change cost **758 MB over
a fling and 1.2 GB over 401 streamed deltas**, p50 3.0 MB per streamed
frame. Three things were wrong and each is now guarded by this rig:
`ArrBuf` reallocated on every length change, so adding one glyph made
the buffer's contents undefined and forced a full rewrite; a redraw
freed its primitives and pushed new ones, which -- since freed slots
are only reusable next frame and provisional layout nested -- grew
the arena to **127,443 slots for 11,569 live primitives**; and nothing
tracked *which* entries changed. Now: the stream arena is 11,569 slots
for 11,569 live, and every array uploads within a hair of its floor.
The CPU half improved with it, since the freeing and renumbering
went away: a streamed frame is p50 1.39ms, from 2.20ms.
The remaining layout cost was then removed at the framework boundary:
`Painter::set_child_offset` gives a container one retained coordinate slot
for its child subtree, and `LazySpan` keeps row boxes stable behind it.
Pinned growth now uploads instances at **1.1% against a 1.1% floor**, from
71.9% against 71.8%; p50 instance upload is **1,488 bytes**, from 176,496.
`Primitives` also cancels dirty marks for provisional writes restored before
upload, so CPU-only layout states never become GPU work.
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader
in software -- while its GLES *is* the host's real GPU through virgl at
ES 3.1, so an ordinary build's runtime fallback lands there by itself
and nothing should pass `force-gles` to arrange it. The Vulkan path is
verified on the desktop build and on Iris's phone. Do not boot the
emulator with SwiftShader Vulkan to "test the Vulkan path": that
measures a software rasteriser and steers iris away from the one
hardware-accelerated backend it has there. Every run says which adapter
drew it (`iris renderer:` in logcat, printed by `run-bench.sh`); read
that line before reading a number.
### Driving the UI ### Driving the UI
@@ -362,13 +546,11 @@ thing to suspect first if a remote spawn ever mangles an argument.
## Where things run (host vs this VM) ## Where things run (host vs this VM)
The machine itself — the two boxes, the shared `~/repos` mount, and why the This checkout runs in a VM while production runs on its host:
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that
means here:
- **`ai-server` belongs on the host in production.** That is where the LAN - **`ai-server` belongs on the host in production.** That is where the LAN
address the phone can reach is, and where WireGuard terminates. address the phone can reach is, and where WireGuard terminates.
`wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it `scripts/wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
there with `sudo WG_ENDPOINT=<ddns name>`. there with `sudo WG_ENDPOINT=<ddns name>`.
- **The tunnel and the real phone can never terminate in the VM**, because - **The tunnel and the real phone can never terminate in the VM**, because
nothing outside can open a connection into it. Phone bring-up is host work. nothing outside can open a connection into it. Phone bring-up is host work.
@@ -456,8 +638,7 @@ where it was instead of half-deleted.
## Things that have bitten ## Things that have bitten
Project-specific only — a lesson that would bite any project on this machine Project-specific only; keep cross-project machine notes out of this file.
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
- **tracing caches callsite interest process-wide.** A test that hits a - **tracing caches callsite interest process-wide.** A test that hits a
`tracing::warn!` with no subscriber installed can poison the interest cache `tracing::warn!` with no subscriber installed can poison the interest cache
-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.
-1128
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
+186 -219
View File
@@ -166,6 +166,28 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "ai-app"
version = "0.1.0"
dependencies = [
"android-view",
"android_logger",
"base64",
"event-model",
"iris",
"jni 0.22.4",
"libc",
"log",
"pulldown-cmark",
"serde",
"serde_json",
"tabs-ui",
"tempfile",
"tokio",
"ureq",
"winit",
]
[[package]] [[package]]
name = "aligned" name = "aligned"
version = "0.4.3" version = "0.4.3"
@@ -566,18 +588,18 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
dependencies = [ dependencies = [
"bit-vec", "bit-vec",
] ]
[[package]] [[package]]
name = "bit-vec" name = "bit-vec"
version = "0.8.0" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
[[package]] [[package]]
name = "bit_field" name = "bit_field"
@@ -606,12 +628,6 @@ dependencies = [
"no_std_io2", "no_std_io2",
] ]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]] [[package]]
name = "block2" name = "block2"
version = "0.5.1" version = "0.5.1"
@@ -621,6 +637,15 @@ dependencies = [
"objc2 0.5.2", "objc2 0.5.2",
] ]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2 0.6.4",
]
[[package]] [[package]]
name = "blocking" name = "blocking"
version = "1.7.0" version = "1.7.0"
@@ -740,17 +765,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"pulldown-cmark",
"serde",
"serde_json",
"ureq",
]
[[package]] [[package]]
name = "clipboard-win" name = "clipboard-win"
version = "5.4.1" version = "5.4.1"
@@ -762,9 +776,9 @@ dependencies = [
[[package]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.12.0" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [ dependencies = [
"serde", "serde",
"termcolor", "termcolor",
@@ -835,16 +849,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -858,8 +862,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"core-foundation 0.9.4", "core-foundation",
"core-graphics-types 0.1.3", "core-graphics-types",
"foreign-types", "foreign-types",
"libc", "libc",
] ]
@@ -871,18 +875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"core-foundation 0.9.4", "core-foundation",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.10.1",
"libc", "libc",
] ]
@@ -897,9 +890,9 @@ dependencies = [
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.7" version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [ dependencies = [
"crossbeam-epoch", "crossbeam-epoch",
"crossbeam-utils", "crossbeam-utils",
@@ -907,18 +900,18 @@ dependencies = [
[[package]] [[package]]
name = "crossbeam-epoch" name = "crossbeam-epoch"
version = "0.9.20" version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.22" version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]] [[package]]
name = "crunchy" name = "crunchy"
@@ -1183,9 +1176,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "font-types" name = "font-types"
version = "0.12.4" version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
] ]
@@ -1388,9 +1381,9 @@ dependencies = [
[[package]] [[package]]
name = "glow" name = "glow"
version = "0.16.0" version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"slotmap", "slotmap",
@@ -1421,26 +1414,6 @@ dependencies = [
"windows", "windows",
] ]
[[package]]
name = "gpu-descriptor"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
dependencies = [
"bitflags 2.13.1",
"gpu-descriptor-types",
"hashbrown 0.15.5",
]
[[package]]
name = "gpu-descriptor-types"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
dependencies = [
"bitflags 2.13.1",
]
[[package]] [[package]]
name = "half" name = "half"
version = "2.7.1" version = "2.7.1"
@@ -1506,12 +1479,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hexf-parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]] [[package]]
name = "http" name = "http"
version = "1.5.0" version = "1.5.0"
@@ -1760,24 +1727,6 @@ dependencies = [
"winit", "winit",
] ]
[[package]]
name = "iris-android-app"
version = "0.1.0"
dependencies = [
"android-view",
"android_logger",
"client-core",
"event-model",
"iris",
"libc",
"log",
"serde_json",
"tabs-ui",
"tokio",
"transcript-fixture",
"transcript-ui",
]
[[package]] [[package]]
name = "iris-core" name = "iris-core"
version = "0.1.0" version = "0.1.0"
@@ -1798,7 +1747,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.5",
] ]
[[package]] [[package]]
@@ -1985,7 +1934,7 @@ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"libc", "libc",
"plain", "plain",
"redox_syscall 0.9.3", "redox_syscall 0.9.4",
] ]
[[package]] [[package]]
@@ -2042,15 +1991,6 @@ dependencies = [
"imgref", "imgref",
] ]
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "maybe-rayon" name = "maybe-rayon"
version = "0.1.1" version = "0.1.1"
@@ -2085,21 +2025,6 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "metal"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15"
dependencies = [
"bitflags 2.13.1",
"block",
"core-graphics-types 0.2.0",
"foreign-types",
"log",
"objc",
"paste",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2132,9 +2057,9 @@ dependencies = [
[[package]] [[package]]
name = "naga" name = "naga"
version = "28.0.0" version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135" checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"bit-set", "bit-set",
@@ -2143,11 +2068,11 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"codespan-reporting", "codespan-reporting",
"half", "half",
"hashbrown 0.16.1", "hashbrown 0.17.1",
"hexf-parse",
"indexmap", "indexmap",
"libm", "libm",
"log", "log",
"naga-types",
"num-traits", "num-traits",
"once_cell", "once_cell",
"rustc-hash", "rustc-hash",
@@ -2156,6 +2081,18 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "naga-types"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
dependencies = [
"hashbrown 0.17.1",
"indexmap",
"rustc-hash",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -2305,15 +2242,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]] [[package]]
name = "objc-sys" name = "objc-sys"
version = "0.3.5" version = "0.3.5"
@@ -2346,13 +2274,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2 0.5.1",
"libc", "libc",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-core-data", "objc2-core-data",
"objc2-core-image", "objc2-core-image",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-quartz-core", "objc2-quartz-core 0.2.2",
] ]
[[package]] [[package]]
@@ -2374,7 +2302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-core-location", "objc2-core-location",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
@@ -2386,7 +2314,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [ dependencies = [
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -2398,7 +2326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -2433,10 +2361,10 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [ dependencies = [
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-metal", "objc2-metal 0.2.2",
] ]
[[package]] [[package]]
@@ -2445,7 +2373,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [ dependencies = [
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-contacts", "objc2-contacts",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
@@ -2474,7 +2402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2 0.5.1",
"dispatch", "dispatch",
"libc", "libc",
"objc2 0.5.2", "objc2 0.5.2",
@@ -2508,7 +2436,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [ dependencies = [
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
@@ -2521,11 +2449,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
[[package]]
name = "objc2-metal"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.13.1",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.2.2" version = "0.2.2"
@@ -2533,10 +2473,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-metal", "objc2-metal 0.2.2",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.1",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"objc2-metal 0.3.2",
] ]
[[package]] [[package]]
@@ -2556,7 +2510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-cloud-kit", "objc2-cloud-kit",
"objc2-core-data", "objc2-core-data",
@@ -2564,7 +2518,7 @@ dependencies = [
"objc2-core-location", "objc2-core-location",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-link-presentation", "objc2-link-presentation",
"objc2-quartz-core", "objc2-quartz-core 0.2.2",
"objc2-symbols", "objc2-symbols",
"objc2-uniform-type-identifiers", "objc2-uniform-type-identifiers",
"objc2-user-notifications", "objc2-user-notifications",
@@ -2576,7 +2530,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [ dependencies = [
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -2588,7 +2542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-core-location", "objc2-core-location",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
@@ -2860,9 +2814,9 @@ dependencies = [
[[package]] [[package]]
name = "pollster" name = "pollster"
version = "0.4.0" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
@@ -3141,6 +3095,18 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "raw-window-metal"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]] [[package]]
name = "rayon" name = "rayon"
version = "1.12.0" version = "1.12.0"
@@ -3198,9 +3164,9 @@ dependencies = [
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.9.3" version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
] ]
@@ -3312,9 +3278,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.43" version = "0.23.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [ dependencies = [
"log", "log",
"once_cell", "once_cell",
@@ -3571,9 +3537,9 @@ dependencies = [
[[package]] [[package]]
name = "spirv" name = "spirv"
version = "0.3.0+sdk-1.3.268.0" version = "0.4.0+sdk-1.4.341.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
] ]
@@ -3865,28 +3831,6 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "transcript-fixture"
version = "0.1.0"
dependencies = [
"client-core",
"event-model",
"iris",
"serde_json",
"transcript-ui",
]
[[package]]
name = "transcript-ui"
version = "0.1.0"
dependencies = [
"client-core",
"event-model",
"iris",
"log",
"pulldown-cmark",
]
[[package]] [[package]]
name = "tree_magic_mini" name = "tree_magic_mini"
version = "3.2.2" version = "3.2.2"
@@ -3957,9 +3901,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "ureq" name = "ureq"
version = "3.4.0" version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"
dependencies = [ dependencies = [
"base64", "base64",
"cookie_store", "cookie_store",
@@ -3977,9 +3921,9 @@ dependencies = [
[[package]] [[package]]
name = "ureq-proto" name = "ureq-proto"
version = "0.6.1" version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10"
dependencies = [ dependencies = [
"base64", "base64",
"http", "http",
@@ -4265,9 +4209,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "wgpu" name = "wgpu"
version = "28.0.0" version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9cb534d5ffd109c7d1135f34cdae29e60eab94855a625dcfe1705f8bc7ad79f" checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"bitflags 2.13.1", "bitflags 2.13.1",
@@ -4275,7 +4219,7 @@ dependencies = [
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"document-features", "document-features",
"hashbrown 0.16.1", "hashbrown 0.17.1",
"js-sys", "js-sys",
"log", "log",
"naga", "naga",
@@ -4295,9 +4239,9 @@ dependencies = [
[[package]] [[package]]
name = "wgpu-core" name = "wgpu-core"
version = "28.0.1" version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d23f4642f53f666adcfd2d3218ab174d1e6681101aef18696b90cbe64d1c10f9" checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"bit-set", "bit-set",
@@ -4306,10 +4250,11 @@ dependencies = [
"bytemuck", "bytemuck",
"cfg_aliases", "cfg_aliases",
"document-features", "document-features",
"hashbrown 0.16.1", "hashbrown 0.17.1",
"indexmap", "indexmap",
"log", "log",
"naga", "naga",
"naga-types",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"portable-atomic", "portable-atomic",
@@ -4322,66 +4267,70 @@ dependencies = [
"wgpu-core-deps-emscripten", "wgpu-core-deps-emscripten",
"wgpu-core-deps-windows-linux-android", "wgpu-core-deps-windows-linux-android",
"wgpu-hal", "wgpu-hal",
"wgpu-naga-bridge",
"wgpu-types", "wgpu-types",
] ]
[[package]] [[package]]
name = "wgpu-core-deps-apple" name = "wgpu-core-deps-apple"
version = "28.0.0" version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87b7b696b918f337c486bf93142454080a32a37832ba8a31e4f48221890047da" checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
dependencies = [ dependencies = [
"wgpu-hal", "wgpu-hal",
] ]
[[package]] [[package]]
name = "wgpu-core-deps-emscripten" name = "wgpu-core-deps-emscripten"
version = "28.0.0" version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b251c331f84feac147de3c4aa3aa45112622a95dd7ee1b74384fa0458dbd79" checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
dependencies = [ dependencies = [
"wgpu-hal", "wgpu-hal",
] ]
[[package]] [[package]]
name = "wgpu-core-deps-windows-linux-android" name = "wgpu-core-deps-windows-linux-android"
version = "28.0.0" version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ca976e72b2c9964eb243e281f6ce7f14a514e409920920dcda12ae40febaae" checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f"
dependencies = [ dependencies = [
"wgpu-hal", "wgpu-hal",
] ]
[[package]] [[package]]
name = "wgpu-hal" name = "wgpu-hal"
version = "28.0.1" version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d6cb474beb218824dcc9e1ce679d973f719262789bfb27407da560cac20eeb" checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
dependencies = [ dependencies = [
"android_system_properties", "android_system_properties",
"arrayvec", "arrayvec",
"ash", "ash",
"bit-set", "bit-set",
"bitflags 2.13.1", "bitflags 2.13.1",
"block", "block2 0.6.2",
"bytemuck", "bytemuck",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"core-graphics-types 0.2.0",
"glow", "glow",
"glutin_wgl_sys", "glutin_wgl_sys",
"gpu-allocator", "gpu-allocator",
"gpu-descriptor", "hashbrown 0.17.1",
"hashbrown 0.16.1",
"js-sys", "js-sys",
"khronos-egl", "khronos-egl",
"libc", "libc",
"libloading", "libloading",
"log", "log",
"metal",
"naga", "naga",
"naga-types",
"ndk-sys", "ndk-sys",
"objc", "objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"objc2-metal 0.3.2",
"objc2-quartz-core 0.3.2",
"once_cell", "once_cell",
"ordered-float", "ordered-float",
"parking_lot", "parking_lot",
@@ -4390,26 +4339,44 @@ dependencies = [
"profiling", "profiling",
"range-alloc", "range-alloc",
"raw-window-handle", "raw-window-handle",
"raw-window-metal",
"renderdoc-sys", "renderdoc-sys",
"smallvec", "smallvec",
"static_assertions",
"thiserror 2.0.20", "thiserror 2.0.20",
"wasm-bindgen", "wasm-bindgen",
"wayland-sys",
"web-sys", "web-sys",
"wgpu-naga-bridge",
"wgpu-types", "wgpu-types",
"windows", "windows",
"windows-core", "windows-core",
"windows-result",
]
[[package]]
name = "wgpu-naga-bridge"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081"
dependencies = [
"naga",
"wgpu-types",
] ]
[[package]] [[package]]
name = "wgpu-types" name = "wgpu-types"
version = "28.0.0" version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c" checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"bytemuck", "bytemuck",
"js-sys", "js-sys",
"log", "log",
"naga-types",
"raw-window-handle",
"static_assertions",
"web-sys", "web-sys",
] ]
@@ -4773,12 +4740,12 @@ dependencies = [
"android-activity", "android-activity",
"atomic-waker", "atomic-waker",
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2 0.5.1",
"bytemuck", "bytemuck",
"calloop", "calloop",
"cfg_aliases", "cfg_aliases",
"concurrent-queue", "concurrent-queue",
"core-foundation 0.9.4", "core-foundation",
"core-graphics", "core-graphics",
"cursor-icon", "cursor-icon",
"dpi", "dpi",
@@ -5077,18 +5044,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.56" version = "0.8.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.56" version = "0.8.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+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"]
File renamed without changes.
@@ -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 public abstract class RustView extends SurfaceView
implements SurfaceHolder.Callback, Choreographer.FrameCallback { implements SurfaceHolder.Callback, Choreographer.FrameCallback {
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view) // Vendored from android-view bec6c62. The only local change is `protected`,
// with one deliberate change: `protected` rather than package-private, so a // allowing IrisView to forward insets through this native peer.
// subclass in a different package (dev.iris.android.demo.IrisView) can pass
// it to the window-insets native call android-view itself has no hook for --
// see iris/src/android/insets.rs's doc comment for why that call exists at
// all. No other line differs from upstream.
protected final long mViewPeer; protected final long mViewPeer;
final InputMethodManager mInputMethodManager; final InputMethodManager mInputMethodManager;
File renamed without changes.
File renamed without changes.
+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 }
}
}
@@ -1,20 +1,5 @@
//! I5's desktop proof: the transcript screen built from synthetic use ai_app::client::QuestionOption;
//! `client_core::transcript_fold` rows (no network, no server -- see use ai_app::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
//! `lib.rs`'s doc for why `transcript-ui` itself never fetches anything),
//! run via `iris/run-headless.sh transcript -- -p transcript-ui` for a
//! screenshot on the winit backend, or `cargo run --example transcript -p
//! transcript-ui` with a real compositor.
//!
//! The rows exercise every one of the seven "hard to get back" behaviours
//! this box's markdown/selection work is meant to show: a heading, bold,
//! italic, an inline code span, a link, a fenced code block (rich inline
//! text), a multi-message conversation (bottom-anchored virtualised list),
//! and a three-call tool run (collapsed by default -- tap it, or drive it
//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove
//! hold-the-edge expand).
use client_core::QuestionOption;
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use iris::prelude::*; use iris::prelude::*;
fn main() { fn main() {
@@ -25,7 +10,7 @@ fn main() {
pub struct Client { pub struct Client {
ui_state: DefaultUiState, ui_state: DefaultUiState,
#[allow(dead_code)] #[allow(dead_code)]
screen: transcript_ui::TranscriptScreen, screen: ai_app::ui::TranscriptScreen,
} }
fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow { fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
@@ -44,17 +29,10 @@ fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
}) })
} }
/// One tool call. `result` is `None` for a call with no result yet and
/// `Some((output, failed))` for one that answered.
fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem { fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem {
tool_call_in("run1", id, tool, input, result) tool_call_in("run1", id, tool, input, result)
} }
/// The same, in a named run. Two runs in one transcript must not share a
/// `run_id`: it is the row's identity in the list (`row::row_key`), and
/// two rows under one key is the duplicate-key fault AGENTS.md's
/// "Importing" section describes. Here it made two rows swap cached
/// heights and draw at each other's boxes.
fn tool_call_in( fn tool_call_in(
run: &str, run: &str,
id: &str, id: &str,
@@ -76,7 +54,6 @@ fn tool_call_in(
} }
} }
/// A call stopped on the reader: one unanswered permission question.
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem { fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
let mut call = tool_call_in("run2", id, tool, input, None); let mut call = tool_call_in("run2", id, tool, input, None);
if let TranscriptItem::ToolRun { asks, .. } = &mut call { if let TranscriptItem::ToolRun { asks, .. } = &mut call {
@@ -104,11 +81,9 @@ fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
call call
} }
/// Longer than the card's own cap, so the "Show all N lines" control is on
/// screen in the expanded shot.
fn long_output() -> String { fn long_output() -> String {
(0..200) (0..200)
.map(|i| format!("test transcript_ui::case_{i} ... ok")) .map(|i| format!("test ai_app::ui::case_{i} ... ok"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n") .join("\n")
} }
@@ -125,12 +100,6 @@ fn synthetic_rows() -> Vec<FoldedRow> {
false, 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```", "# 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```",
), ),
// Every state a tool card has to draw, in one run (P1b): a call
// that worked, one the tool reported as failed, one whose result
// never arrived, and one still running. The last two look the same
// in the events -- an empty output and `done: false` -- and are
// told apart only by whether the session is still working, which
// is what `TranscriptScreen::set_session_working` says.
FoldedRow::Tools(vec![ FoldedRow::Tools(vec![
tool_call( tool_call(
"t1", "t1",
@@ -149,9 +118,6 @@ fn synthetic_rows() -> Vec<FoldedRow> {
), ),
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None), tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
]), ]),
// A lone call is a card too rather than a group of one -- and this
// one carries the kilobyte output a collapsed card must not lay
// out.
FoldedRow::Single(tool_call( FoldedRow::Single(tool_call(
"t5", "t5",
"Bash", "Bash",
@@ -159,19 +125,10 @@ fn synthetic_rows() -> Vec<FoldedRow> {
Some((&long_output(), false)), Some((&long_output(), false)),
)), )),
msg(6, true, "Looks good, thanks!"), msg(6, true, "Looks good, thanks!"),
// Every block kind `client_core::markdown_blocks` names, in one
// row, so P1a's appearance can be looked at against the Compose
// app's without a server (docs/RUST.md's P1a box). The heading,
// paragraph, fence and table are the *same source* the bench
// fixture carries (`app/bench-fixture/generate.py`), so the two
// screenshots differ only in the renderer; the list and the quote
// are extra, because the fixture has neither.
msg(7, false, BLOCK_SAMPLER), msg(7, false, BLOCK_SAMPLER),
] ]
} }
/// One of each markdown block, for the P1a screenshot pair. See
/// [`synthetic_rows`].
const BLOCK_SAMPLER: &str = "\ const BLOCK_SAMPLER: &str = "\
## What changed ## What changed
@@ -181,7 +138,6 @@ iris measure iris scroll call transcript layout *cursor* context, and a \
```rust ```rust
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> { fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
// a comment worth keeping: this is the fold the app's own screen runs
let mut out = items; let mut out = items;
out.push(Item::new(seq)); out.push(Item::new(seq));
out out
@@ -207,11 +163,7 @@ impl DefaultAppState for Client {
rsc: &mut DefaultRsc<Self>, rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>, _: Proxy<Self::Event>,
) -> Self { ) -> Self {
let screen = transcript_ui::build(rsc, &mut ui_state, synthetic_rows()); let screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows());
// Exercises `push_row`/`ItemKey` beyond construction time, matching
// how a live SSE loop appends -- a row arriving after the screen
// already exists must land at the bottom without disturbing what's
// above it (I3's `push_back`/`snap_end`).
screen.push_row( screen.push_row(
rsc, rsc,
&FoldedRow::Single(TranscriptItem::CommandRow { &FoldedRow::Single(TranscriptItem::CommandRow {
@@ -219,11 +171,6 @@ impl DefaultAppState for Client {
text: "clear".into(), text: "clear".into(),
}), }),
); );
// A second run at the live end, so the *running* state is on
// screen too. It cannot share a row with "no result": the two are
// the same events and are told apart only by whether the session
// is working, which is a property of the row rather than of the
// call (`TranscriptScreen::set_session_working`).
screen.push_row( screen.push_row(
rsc, rsc,
&FoldedRow::Tools(vec![ &FoldedRow::Tools(vec![
@@ -242,12 +189,6 @@ impl DefaultAppState for Client {
Some(("error: unused variable `x`", true)), Some(("error: unused variable `x`", true)),
), ),
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None), tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
// Waiting on a permission, so this card is drawn *open*
// whatever the reader last chose -- the command is the
// thing being decided, and a row saying only "Bash"
// cannot be decided on. It is also how the expanded card
// (input block, output block, timeout) gets into the
// screenshot without a finger.
asking( asking(
"t9", "t9",
"Bash", "Bash",
@@ -256,9 +197,6 @@ impl DefaultAppState for Client {
]), ]),
); );
screen.set_session_working(rsc, true); screen.set_session_working(rsc, true);
// The expanded picture has no other way to be looked at on a
// machine with no display and no finger -- see `run-headless.sh`
// and docs/RUST.md's P1b box.
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() { if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
assert!( assert!(
screen.expand_tail_tools(rsc, true), screen.expand_tail_tools(rsc, true),
@@ -7,7 +7,8 @@
# #
# Usage: ./run-bench.sh [--apk PATH] # Usage: ./run-bench.sh [--apk PATH]
# Defaults to this checkout's own release APK # Defaults to this checkout's own release APK
# (app/build/outputs/apk/release/app-release.apk) if it exists, else the # (android-project/app/build/outputs/apk/release/app-release.apk) if it
# exists, else the
# debug one -- build one first with ./build-apk.sh. # debug one -- build one first with ./build-apk.sh.
set -eu set -eu
cd "$(dirname "$0")" cd "$(dirname "$0")"
@@ -20,10 +21,10 @@ while [ $# -gt 0 ]; do
esac esac
done done
if [ -z "$APK" ]; then if [ -z "$APK" ]; then
if [ -f app/build/outputs/apk/release/app-release.apk ]; then if [ -f android-project/app/build/outputs/apk/release/app-release.apk ]; then
APK=app/build/outputs/apk/release/app-release.apk APK=android-project/app/build/outputs/apk/release/app-release.apk
else else
APK=app/build/outputs/apk/debug/app-debug.apk APK=android-project/app/build/outputs/apk/debug/app-debug.apk
fi fi
fi fi
if [ ! -f "$APK" ]; then if [ ! -f "$APK" ]; then
@@ -46,6 +47,21 @@ adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/d
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
# Which adapter drew, before any number is printed. The emulator is a GLES
# machine -- its guest has no hardware Vulkan (docs/RUST.md, "What the
# emulator gives a GPU app") -- so iris's runtime fallback lands on `Gl`,
# and `Gl (... virgl ...)` is the host's real GPU while `Gl (...
# SwiftShader ...)` is the CPU. Those two produce frame times an order of
# magnitude apart and are otherwise indistinguishable in this report, so
# the line is printed rather than left in logcat for somebody to think of.
ADAPTER=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null \
| sed -n 's/.*\(iris renderer: .*\)/\1/p' | tail -1)
if [ -n "$ADAPTER" ]; then
echo "run-bench.sh: $ADAPTER"
else
echo "run-bench.sh: no 'iris renderer:' line in logcat -- cannot say what drew this run" >&2
fi
# Poll for the report line rather than a fixed sleep -- the run itself is # Poll for the report line rather than a fixed sleep -- the run itself is
# a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming # a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end # phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
+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()
);
}
@@ -1,25 +1,6 @@
//! P0's iris half (docs/RUST.md's P0 box, docs/AGENTS.md's "The rigs"): use crate::android::bench_jni::PlatformHandle;
//! the same fixture, scroll loop and streaming phase the Compose `bench` use crate::client::transcript_fold::{TranscriptItem, fold_event};
//! build type's `BenchRun.kt`/`BenchFixture.kt` drive, run here against
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
//! that measures the renderer rather than the data or the network.
//!
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
//! `TranscriptScreen::apply` incremental update on every event) with the
//! network half replaced by the checked-in fixture. Reading that fixture
//! and folding it into a screen is **`transcript-fixture`'s** job, not
//! this file's -- the same crate the headless harness and the
//! phone-shaped desktop window open, so all three measure one screen
//! (AGENTS.md's sharing rule; moved out of here 2026-09-07). The tail is
//! replayed one at a time through `fold_event` -- the same fold path a
//! live SSE reply arrives on -- by the "Run benchmark" control below.
//! Streaming through `apply` rather than a full rebuild per event is what
//! this file exists to measure -- see docs/RUST.md's P0 box for the
//! before/after report.
use crate::bench_jni::PlatformHandle;
use android_view::jni::{JavaVM, objects::GlobalRef}; use android_view::jni::{JavaVM, objects::GlobalRef};
use client_core::transcript_fold::{TranscriptItem, fold_event};
use event_model::SeqEvent; use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*; use iris::prelude::*;
@@ -27,35 +8,16 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
/// implement the identical four phases -- see that box before changing any
/// constant here, since a mismatch would make the two reports stop
/// measuring the same thing while still looking like they do.
const STREAM_EVENTS_PER_SEC: u64 = 20; const STREAM_EVENTS_PER_SEC: u64 = 20;
const STREAM_SECONDS: u64 = 20; const STREAM_SECONDS: u64 = 20;
/// Kept only so this phase's own label text still reads "scroll: 6 cycles
/// (24 swipes, legacy tween)" the way `BenchRun.kt`'s v2 report does --
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s own report shows this
/// exact line even though the swipe loop it names no longer runs there
/// either (the fling phase replaced it); nothing here drives an actual
/// swipe with these any more.
const LEGACY_CYCLES: usize = 6; const LEGACY_CYCLES: usize = 6;
/// Fling phase (v2): a real fling through `List::fling`, not a tween --
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a
/// tween can never exceed the distance/time it is given while a real
/// fling decays from an initial velocity the way a finger flick does.
/// 12,000 px/s matches `BenchRun.kt`'s own constant exactly.
const FLING_VELOCITY_PX_S: f32 = 12_000.0; const FLING_VELOCITY_PX_S: f32 = 12_000.0;
const FLING_COUNT: usize = 8; const FLING_COUNT: usize = 8;
const FLING_SETTLE_CAP_MS: u64 = 3_000; const FLING_SETTLE_CAP_MS: u64 = 3_000;
const FLING_PAUSE_MS: u64 = 300; const FLING_PAUSE_MS: u64 = 300;
/// Type phase (v2): long, multisyllabic words so the composer actually
/// wraps and the transcript above it is pushed upward, typed and deleted
/// one character per `TYPE_CHAR_MS`. Exactly `BenchRun.TYPE_TEXT` --
/// verified 600 characters by `type_text_is_exactly_600_characters` below.
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \ const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \ multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \ counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
@@ -66,70 +28,29 @@ keyboard-adjacent box, which is exactly what a real reader typing a long message
happening now!!!"; happening now!!!";
const TYPE_CHAR_MS: u64 = 50; const TYPE_CHAR_MS: u64 = 50;
/// Keyboard phase (v2): five show/hide cycles, a second apart, matching
/// `BenchRun.kt`'s `KEYBOARD_CYCLES`/`KEYBOARD_SHOW_WAIT_MS`/
/// `KEYBOARD_HIDE_WAIT_MS`.
const KEYBOARD_CYCLES: usize = 5; const KEYBOARD_CYCLES: usize = 5;
const KEYBOARD_WAIT_MS: u64 = 1_000; const KEYBOARD_WAIT_MS: u64 = 1_000;
/// One animation step's target cadence -- close enough to 60Hz that a const POLL_MS: u64 = 16;
/// fling/scroll is many small moves rather than one jump, so frames are
/// actually rendered along the way, and close enough that a `ctx.update`
/// closure's effect (only applied once the next frame callback drains the
/// task channel -- `IrisViewPeer::drain_tasks`) is visible again quickly
/// when a later step in the same phase needs to read state back.
const ANIM_STEP_MS: u64 = 16;
/// How much of the screen a *filled* benchmark report may take before it
/// scrolls instead of growing -- roughly a third of a phone screen, the
/// share the pane used to reserve unconditionally. An empty report takes
/// nothing at all; see `new`'s comment at the tree it is used in.
const REPORT_MAX_HEIGHT_DP: f32 = 260.0; const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
pub struct BenchClient { pub struct BenchClient {
ui_state: AndroidUiState, ui_state: AndroidUiState,
content: WeakWidget<WidgetPtr>, content: WeakWidget<WidgetPtr>,
report_display: WeakWidget<TextEdit>, report_display: WeakWidget<TextEdit>,
/// The top button row, in a `WidgetPtr` slot rather than added
/// directly (like `content`) so `on_insets_changed` can swap in a
/// version padded for the status bar once insets are known -- RUST.md's
/// P0 box, "the status-bar inset is not applied," found the row sitting
/// directly under it because nothing here read `insets().top` at all.
top_bar: WeakWidget<WidgetPtr>, top_bar: WeakWidget<WidgetPtr>,
screen: Option<transcript_ui::TranscriptScreen>, screen: Option<crate::ui::TranscriptScreen>,
items: Vec<TranscriptItem>, items: Vec<TranscriptItem>,
/// The events not yet streamed -- consumed by `start_benchmark`'s own
/// clone, kept here only as the source a second run would need (the
/// button can be pressed more than once; `running` just stops overlap,
/// not repeat).
stream_tail: Vec<SeqEvent>, stream_tail: Vec<SeqEvent>,
platform: Option<Arc<PlatformHandle>>, platform: Option<Arc<PlatformHandle>>,
last_report: Option<String>, last_report: Option<String>,
running: bool, running: bool,
/// The keyboard phase's own confirmation channel -- updated from
/// `on_insets_changed` (the platform's own answer for whether the IME
/// is actually visible, per `WindowInsets::ime_bottom`), read from the
/// benchmark's spawned task via the shared `Arc<Mutex<_>>` rather than
/// `ctx.update`, since neither side needs the widget tree for this.
ime_state: Arc<Mutex<ImeState>>, ime_state: Arc<Mutex<ImeState>>,
/// Edge-triggers the keyboard diagnostics capture below -- set on the
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
/// first where it is not, so opening the keyboard fires this once
/// rather than on every insets update while it stays open (a rotation
/// or a status-bar change with the keyboard already up would otherwise
/// re-fire it).
keyboard_was_visible: bool, keyboard_was_visible: bool,
/// The status-bar inset `top_bar` was last padded by -- see
/// `on_insets_changed`'s own comment for why this guards the rebuild.
last_top_pad: f32, last_top_pad: f32,
} }
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
/// count real 0->visible / visible->0 transitions `on_insets_changed`
/// observed, not merely "a show/hide was requested" -- UI_RULES.md: never
/// present an inferred value as a measured one. `run_keyboard_phase` reads
/// the counters before and after asking for a toggle and calls it
/// confirmed only if the count moved.
#[derive(Default)] #[derive(Default)]
struct ImeState { struct ImeState {
visible: bool, visible: bool,
@@ -148,17 +69,13 @@ impl HasAndroidUiState for BenchClient {
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget { fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string()) wtext(message.to_string())
.color(Color::WHITE) .color(PaintId::WHITE)
.wrap(true) .wrap(true)
.pad(16) .pad(16)
.add_strong(rsc) .add_strong(rsc)
.any() .any()
} }
/// `getrusage(RUSAGE_SELF)`'s user+system time, in ms -- `None` only if
/// the syscall itself fails, which UI_RULES.md's "never present an
/// inferred value as a measured one" says to keep apart from a real (and
/// here, impossible) zero.
fn process_cpu_ms() -> Option<u64> { fn process_cpu_ms() -> Option<u64> {
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully // SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
// initialises on success; on failure it is never read. // initialises on success; on failure it is never read.
@@ -173,9 +90,6 @@ fn process_cpu_ms() -> Option<u64> {
} }
} }
/// `VmHWM` from `/proc/self/status` -- the process's peak RSS since it
/// started, in kB. Same source `BenchRun.kt`'s `peakRssLine` reads, so the
/// two reports' numbers mean the same thing.
fn peak_rss_kb() -> Option<u64> { fn peak_rss_kb() -> Option<u64> {
std::fs::read_to_string("/proc/self/status") std::fs::read_to_string("/proc/self/status")
.ok()? .ok()?
@@ -190,12 +104,6 @@ fn battery_line(samples: &[i32]) -> String {
return " battery current: unavailable on this device".to_string(); return " battery current: unavailable on this device".to_string();
} }
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64; let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
// `min`/`max` are guarded by the `is_empty` check above, three lines
// up -- pairing the `Option` unwraps with the emptiness check right
// here (rather than two statements apart, with `mean` in between
// reading the same slice) is what keeps a future reorder from
// separating the guard from what it protects (docs/
// REVIEW-2026-09-06.md finding 7).
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else { let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
unreachable!("samples is non-empty, checked above"); unreachable!("samples is non-empty, checked above");
}; };
@@ -216,7 +124,7 @@ impl AndroidAppState for BenchClient {
.text_align(Align::LEFT) .text_align(Align::LEFT)
.wrap(true) .wrap(true)
.size(14) .size(14)
.color(Color::WHITE) .color(PaintId::WHITE)
.attr::<Selectable>(()) .attr::<Selectable>(())
.label("Benchmark report") .label("Benchmark report")
.add(rsc); .add(rsc);
@@ -224,22 +132,6 @@ impl AndroidAppState for BenchClient {
let top_bar = WidgetPtr::new().add(rsc); let top_bar = WidgetPtr::new().add(rsc);
let controls = bench_controls(rsc, 0.0); let controls = bench_controls(rsc, 0.0);
top_bar(rsc).set(controls); top_bar(rsc).set(controls);
// The report pane is sized to whatever report it is holding, not
// to a share of the window: `rest(1)` here reserved a third of
// the screen for an *empty* `TextEdit` at every launch, which is
// what Iris's 2026-09-06 11:39 phone report described as "the app
// does not start with keyboard spacing correct" -- the composer
// two thirds down with black below it, nothing to do with the IME
// inset (measured: `iris insets:` reports bottom=63 ime_bottom=0
// at launch, while the `Message` field's own box sat 789px above
// the bottom of a 2282px surface -- exactly this pane's third).
// Capped and scrollable so a long report cannot take the screen
// back over, the same idiom `composer.rs` uses for the field.
// Above the transcript, not below it: the report is what the
// header's own "Run benchmark" button produces (UI_RULES.md --
// results appear where the action was started), and a pane under
// the composer would eat the navigation-bar clearance
// `set_bottom_inset` gives it.
let tree = ( let tree = (
top_bar, top_bar,
report_display report_display
@@ -250,16 +142,12 @@ impl AndroidAppState for BenchClient {
.span(Dir::DOWN) .span(Dir::DOWN)
.add_strong(rsc) .add_strong(rsc)
.any(); .any();
ui_state.set_root(tree); ui_state.set_root(rsc, tree);
// Startup log line (RUST.md's P0 box, "log once at startup ... the
// number of font families found, the default family resolved"):
// what font discovery actually found on this device, before
// anything is drawn.
let font = rsc.ui.text.font_diagnostics(); let font = rsc.ui.text.font_diagnostics();
log::info!( log::info!(
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \ "iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
bold={:?} italic={:?} mono={:?}", bold={:?} italic={:?} mono={:?}, icons={:?}",
font.families_found, font.families_found,
font.default_family, font.default_family,
font.default_mono_family, font.default_mono_family,
@@ -267,6 +155,7 @@ impl AndroidAppState for BenchClient {
font.bold_resolved, font.bold_resolved,
font.italic_resolved, font.italic_resolved,
font.mono_resolved, font.mono_resolved,
font.icon_family,
); );
let mut client = Self { let mut client = Self {
@@ -285,7 +174,7 @@ impl AndroidAppState for BenchClient {
last_top_pad: 0.0, last_top_pad: 0.0,
}; };
match transcript_fixture::build_screen(rsc) { match crate::ui::fixture::build_screen(rsc) {
Ok((opened, tree)) => { Ok((opened, tree)) => {
client.items = opened.items; client.items = opened.items;
client.stream_tail = opened.stream_tail; client.stream_tail = opened.stream_tail;
@@ -307,47 +196,6 @@ impl AndroidAppState for BenchClient {
false false
} }
/// Pads the top button row by the status-bar inset -- see `top_bar`'s
/// field comment. Rebuilds the row rather than mutating a stored
/// `Padding` in place, since nothing here holds a handle to one --
/// but **only when `insets.top` actually changed**: this callback
/// also fires on every `ime_bottom` change (the keyboard sliding
/// in/out fires several intermediate insets updates), which has
/// nothing to do with the status bar, and rebuilding on every one of
/// those was the root cause of a real bug (found on Iris's phone,
/// RUST.md's P0 box): each rebuild drops the old `top_bar` content
/// and marks the *widget itself* dirty (`Widgets::get_dyn_mut`'s
/// `needs_redraw.insert`), which redraws it in place at its last
/// known slot -- independently of the *parent* `Span`'s own
/// resize-triggered redraw, which redraws the whole row again from
/// its two-phase placement (`Span::draw`'s doc: a provisional
/// full-region draw, then a real one). A `.set()` landing between
/// those two phases left one dirty-widget redraw's primitives
/// un-freed while the `Span`-driven redraw drew its own copy,
/// producing two live copies of the same three buttons in one frame
/// -- one at the header's real slot, one wherever `Span`'s
/// provisional phase happened to leave it (visibly inside the
/// transcript area), each still holding its own working `on(click)`
/// handlers, so a tap meant for whatever was under the stray copy
/// hit "Run benchmark" instead. Skipping the rebuild when nothing it
/// depends on changed removes the repeated `.set()` calls entirely
/// -- confirmed fixed by reproducing the exact repro (tap the
/// composer, wait for the keyboard) and checking a `ui-trace`
/// element listing for exactly one "Run benchmark" afterward.
///
/// Also two things downstream of the same `ime_bottom` transition:
/// **the keyboard phase's own confirmation signal** (`ime_state`'s
/// doc -- the platform's own answer for whether the IME actually
/// opened or closed, rather than assumed from having called
/// `show_ime`/`hide_ime`), and **the trigger for the keyboard
/// diagnostics capture** (RUST.md's P0 box): the IME resizing the
/// surface is exactly the case a previous commit found wiped text,
/// and Iris needs a way to get a report off the phone even if that
/// (or some other keyboard-triggered regression) is still happening
/// on the build she is holding -- `capture_keyboard_diagnostics`
/// below fires ~500ms after the keyboard becomes visible, once per
/// keyboard opening, and shows its report in a plain overlay view
/// that draws independently of whatever iris itself is doing.
fn on_insets_changed( fn on_insets_changed(
&mut self, &mut self,
rsc: &mut AndroidRsc<Self>, rsc: &mut AndroidRsc<Self>,
@@ -359,12 +207,6 @@ impl AndroidAppState for BenchClient {
(self.top_bar)(rsc).set(controls); (self.top_bar)(rsc).set(controls);
} }
// The composer bar sits directly on whichever of the IME or the
// navigation bar is currently the bottom of usable space -- see
// `transcript_ui::composer::Composer::set_bottom_inset`'s doc.
// `ime_bottom` already exceeds the plain nav-bar inset whenever the
// keyboard covers it, so the larger of the two is always the right
// answer without needing to know which is currently showing.
if let Some(screen) = &self.screen { if let Some(screen) = &self.screen {
screen screen
.composer .composer
@@ -404,46 +246,50 @@ impl AndroidAppState for BenchClient {
} }
} }
/// How long to wait after the keyboard becomes visible before capturing
/// diagnostics -- long enough that the resize, the reported wipe (if it is
/// still happening) and a couple of frames have all had time to land, per
/// AGENTS.md's "so that operations that finish in milliseconds have states
/// on the way that nothing can observe" reasoning applied the other way:
/// this wants to observe the state *after* the transition settles, not
/// mid-flight.
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500; const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
type Rsc = AndroidRsc<BenchClient>; type Rsc = AndroidRsc<BenchClient>;
/// What a report says about the `iris::input`/`iris::frame` trace, from
/// the flag read at the start of what is being reported and again at the
/// end.
///
/// Three answers rather than two. Those lines are default-off and the
/// switch that turns them on is on screen while a benchmark runs, so
/// "somebody moved it half way through" is a state that actually happens
/// -- and reported as either "on" or "off" it is a confident sentence
/// about a log that only covers part of the run. The "on" wording also
/// says what it costs, because a traced run fills the ring in seconds and
/// a reader looking at a log with nothing else in it should know why.
fn trace_line(at_start: bool, at_end: bool) -> String {
match (at_start, at_end) {
(true, true) => "input/frame trace: on (iris::input and iris::frame lines are in \
the app log, and a traced run fills the ring in seconds)"
.to_string(),
(false, false) => "input/frame trace: off".to_string(),
_ => "input/frame trace: switched during this run, so those lines cover only part \
of it"
.to_string(),
}
}
/// The header row's own backdrop -- see `bench_controls`'s doc comment on /// The header row's own backdrop -- see `bench_controls`'s doc comment on
/// why it needs one at all. A dark neutral rather than pure black /// why it needs one at all. A dark neutral rather than pure black
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel /// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
/// instead of a hole in the background the buttons happen to float in. /// instead of a hole in the background the buttons happen to float in.
const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255); const HEADER_SURFACE: Srgba8 = Srgba8::new(28, 28, 34, 255);
/// `top_pad` is the status-bar inset in physical pixels (0.0 until /// `top_pad` is the status-bar inset in physical pixels (0.0 until
/// `on_insets_changed` has run once) -- folded in here, rather than /// `on_insets_changed` has run once) -- folded in here, rather than
/// exposing the unadded builder for a caller to `.pad()` itself, because /// exposing the unadded builder for a caller to `.pad()` itself, because
/// naming that builder's type at each call site is more machinery than a /// naming that builder's type at each call site is more machinery than a
/// top-of-screen padding number is worth. /// top-of-screen padding number is worth.
/// const HEADER_TEXT: f32 = 18.0;
/// **Backed by an opaque rect the full size of the row, not just the three
/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
/// build a9232ac): "the header buttons have nothing behind them and
/// overlap the transcript text" -- before this, only each button's own
/// `rect(...)` painted anything, so the gaps between and around them (and
/// the status-bar strip above them) showed whatever was one layer back
/// (`CLEAR_COLOR`, black), and the row's true height was three
/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the
/// density-correct size the transcript below was already using post-P0 --
/// exactly what reads as "overlap" once the two disagree. Fixed two ways
/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this
/// function), and every size below moved from a bare number (physical
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
/// so the row's reserved height in the outer `Span::DOWN`
/// (`AndroidAppState::new`) matches what is actually painted.
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget { fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let run_rect = rect(Color::rgb(40, 70, 40)) let run_rect = rect(Srgba8::rgb(40, 70, 40))
.on( .on(
CursorSense::click(), CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| { |ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
@@ -453,29 +299,33 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
.label("Run benchmark"); .label("Run benchmark");
let run = ( let run = (
run_rect, run_rect,
wtext("Run benchmark").size(18).text_align(Align::CENTER), wtext("Run benchmark")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
) )
.stack() .stack()
.pad(dp(8)) .pad(dp(8))
.add(rsc); .add(rsc);
let copy_rect = rect(Color::rgb(50, 50, 60)) let copy_rect = rect(Srgba8::rgb(50, 50, 60))
.on( .on(
CursorSense::click(), CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| { |ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.copy_report(); ctx.state.copy_report(rsc);
}, },
) )
.label("Copy report"); .label("Copy report");
let copy = ( let copy = (
copy_rect, copy_rect,
wtext("Copy report").size(18).text_align(Align::CENTER), wtext("Copy report")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
) )
.stack() .stack()
.pad(dp(8)) .pad(dp(8))
.add(rsc); .add(rsc);
let diag_rect = rect(Color::rgb(60, 45, 70)) let diag_rect = rect(Srgba8::rgb(60, 45, 70))
.on( .on(
CursorSense::click(), CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| { |ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
@@ -485,17 +335,50 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
.label("Diagnostics"); .label("Diagnostics");
let diagnostics = ( let diagnostics = (
diag_rect, diag_rect,
wtext("Diagnostics").size(18).text_align(Align::CENTER), wtext("Diagnostics")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
) )
.stack() .stack()
.pad(dp(8)) .pad(dp(8))
.add(rsc); .add(rsc);
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc); // A switch rather than a button, so its own appearance says which
// state it is in: the two `iris::input`/`iris::frame` targets are
// default-off (`iris::diagnostics`'s module doc) because a 120Hz
// session fills the 2000-line ring in seconds, so "is it on right
// now" is the question somebody has while looking at a log that is
// either full of trace or has none.
let tracing = iris::diagnostics::trace_enabled();
let trace_rect = rect(if tracing {
Srgba8::rgb(90, 70, 30)
} else {
Srgba8::rgb(50, 50, 60)
})
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.toggle_trace(rsc);
},
)
.label("Trace input and frames");
let trace = (
trace_rect,
wtext(if tracing { "Trace on" } else { "Trace off" })
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
(rect(HEADER_SURFACE), buttons) (rect(HEADER_SURFACE), buttons)
.stack() .stack()
.height(dp(56)) .height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
.pad(Padding::top(top_pad)) .pad(Padding::top(top_pad))
.add_strong(rsc) .add_strong(rsc)
.any() .any()
@@ -509,23 +392,31 @@ impl BenchClient {
} }
fn rebuild_transcript(&mut self, rsc: &mut Rsc) { fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
let (screen, tree) = transcript_ui::build_tree(rsc, transcript_fixture::rows(&self.items)); let (screen, tree) = crate::ui::build_tree(rsc, crate::ui::fixture::rows(&self.items));
(self.content)(rsc).set(tree); (self.content)(rsc).set(tree);
self.screen = Some(screen); self.screen = Some(screen);
} }
/// RUST.md's P0 box: "a named `Diagnostics` control ... with 'copy this
/// and send it to Iris'." Fills `report_display` (the same TextEdit the
/// benchmark report uses) rather than a separate widget, so the
/// existing "Copy report" button and clipboard path work on whichever
/// text is currently shown -- `last_report` is what `copy_report` reads,
/// so it's set here too rather than adding a second copy path.
fn show_diagnostics(&mut self, rsc: &mut Rsc) { fn show_diagnostics(&mut self, rsc: &mut Rsc) {
let report = self.diagnostics_text(rsc); let report = self.diagnostics_text(rsc);
self.report_display.edit(rsc).set(&report); self.report_display.edit(rsc).set(&report);
self.last_report = Some(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 /// The diagnostics report as text, with no side effect on what is on
/// screen -- shared by the `Diagnostics` button (which shows it) and /// screen -- shared by the `Diagnostics` button (which shows it) and
/// the keyboard-open capture (which only logs it), so the two can /// the keyboard-open capture (which only logs it), so the two can
@@ -540,51 +431,48 @@ impl BenchClient {
Some(renderer) => renderer.diagnostics_report(&font, &frame_report), Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
None => "iris diagnostics: no renderer yet (no surface)".to_string(), None => "iris diagnostics: no renderer yet (no surface)".to_string(),
}; };
// The insets line goes in the pane, not just the log: Iris has no // Insets must be visible without adb so a missing callback can be
// logcat on her phone, and "the keyboard does not push the // distinguished from a callback reporting zero IME height.
// composer up" cannot be told from "the listener never fired" format!(
// without it (`AndroidUiState::insets_report`). "{renderer}\n{}\n{}\n{}\n{}",
format!("{renderer}\n{}", self.android_state().insets_report()) trace_line(
iris::diagnostics::trace_enabled(),
iris::diagnostics::trace_enabled()
),
self.android_state().insets_report(),
// Which server this build talks to, and what to do when the
// answer is "none" -- the bench itself opens a checked-in
// fixture and needs no server, so this pane is the only place
// an enrolment can be seen to have taken.
crate::android::enrollment::status_line(),
crate::android::app_log::diagnostics_line()
)
} }
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
/// doc comment. **Logged only.** It used to also copy the report to
/// the clipboard unprompted and put it in the shell's overlay view,
/// from when the keyboard-inset callback was not firing at all and a
/// report could not be got off the phone any other way. Both are gone
/// as of 2026-09-06: the callback fires reliably now (edge-to-edge,
/// `MainActivity.java`), and the overlay covered the whole screen on
/// *every* keyboard open with its own Copy/Close buttons underneath
/// the keyboard, so it could not be dismissed -- an interruption for
/// something nobody asked for, over an app you are trying to type
/// into (UI_RULES.md). The named `Diagnostics` button still shows the
/// same text on demand, and `iris surface:`/`iris insets:` (view.rs)
/// carry the lifecycle a `logcat` pull actually needs.
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) { fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
let report = self.diagnostics_text(rsc); let report = self.diagnostics_text(rsc);
log::info!("iris keyboard diagnostics:\n{report}"); log::info!("iris keyboard diagnostics:\n{report}");
} }
fn copy_report(&mut self) { fn copy_report(&mut self, rsc: &mut Rsc) {
let Some(report) = &self.last_report else {
log::info!("iris bench report: nothing to copy -- run the benchmark first");
return;
};
let Some(platform) = &self.platform else { let Some(platform) = &self.platform else {
log::info!("iris bench report: no platform handle, can't reach the clipboard"); log::info!("iris bench report: no platform handle, can't reach the clipboard");
return; return;
}; };
if platform.copy_to_clipboard("iris bench report", report) { let report = match self.last_report.clone() {
Some(report) => report,
None => format!(
"no benchmark has run yet -- these are the diagnostics instead:\n\n{}",
self.diagnostics_text(rsc)
),
};
if platform.copy_to_clipboard("iris bench report", &report) {
log::info!("iris bench report: copied to clipboard"); log::info!("iris bench report: copied to clipboard");
} else { } else {
log::info!("iris bench report: clipboard copy failed"); log::info!("iris bench report: clipboard copy failed");
} }
} }
/// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1),
/// then type, then keyboard, then the report -- run in-process for the
/// same reason `BenchRun.kt`'s own doc gives (no usable system tracing
/// on a real phone, no agent that can drive one).
fn start_benchmark(&mut self, rsc: &mut Rsc) { fn start_benchmark(&mut self, rsc: &mut Rsc) {
if self.running { if self.running {
log::info!("iris bench report: already running"); log::info!("iris bench report: already running");
@@ -598,11 +486,13 @@ impl BenchClient {
let platform = self.platform.clone(); let platform = self.platform.clone();
let stream_tail = self.stream_tail.clone(); let stream_tail = self.stream_tail.clone();
let ime_state = self.ime_state.clone(); let ime_state = self.ime_state.clone();
let refresh_hz = platform let platform_hz = platform.as_ref().and_then(|p| p.refresh_rate_hz());
.as_ref()
.and_then(|p| p.refresh_rate_hz())
.unwrap_or(60.0);
let cpu_start = process_cpu_ms(); let cpu_start = process_cpu_ms();
// Read at the start as well as the end, because the switch is on
// screen while a run is going: a report that only asked afterwards
// would say "on" about a run whose first half has no trace in it
// -- the inferred answer presented as the measured one.
let trace_at_start = iris::diagnostics::trace_enabled();
let run_started_at = Instant::now(); let run_started_at = Instant::now();
rsc.spawn_task(async move |mut ctx| { rsc.spawn_task(async move |mut ctx| {
@@ -653,6 +543,26 @@ impl BenchClient {
ctx.update(move |state: &mut BenchClient, rsc| { ctx.update(move |state: &mut BenchClient, rsc| {
state.running = false; state.running = false;
let now = Instant::now(); let now = Instant::now();
let drawn_hz = state.android_state().frame_report.sustained_frame_hz();
let refresh_hz = match (drawn_hz, platform_hz) {
(Some(d), Some(p)) => d.max(p),
(Some(d), None) => d,
(None, Some(p)) => p,
(None, None) => 60.0,
};
let hz_line = match (drawn_hz, platform_hz) {
(Some(d), Some(p)) if d > p + 5.0 => format!(
" (sustained {d:.0}fps, so at least that; the display reported {p:.0}Hz)"
),
(Some(d), Some(_)) => format!(" (as the display reports it; drew {d:.0}fps)"),
(Some(d), None) => {
format!(" (sustained {d:.0}fps; the display would not say)")
}
(None, Some(_)) => {
" (as the display reports it; too few frames to measure)".to_string()
}
(None, None) => " (assumed -- neither measured nor reported)".to_string(),
};
let phase_lines: String = state let phase_lines: String = state
.android_state() .android_state()
.frame_report .frame_report
@@ -670,9 +580,11 @@ impl BenchClient {
let (late, late_pct) = let (late, late_pct) =
state.android_state().frame_report.late_at_hz(refresh_hz); state.android_state().frame_report.late_at_hz(refresh_hz);
format!( format!(
"frames:\n {} frames over {:.1}s at {:.0}Hz ({:.1}ms budget)\n \ "frames:\n {} frames over {:.1}s at {:.0}Hz{hz_line} ({:.1}ms \
budget)\n \
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \ late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
p99 {:.1}ms\n worst {:.1}ms\n cpu_p50 {:.1}ms gpu_wait_p50 {:.1}ms", p99 {:.1}ms\n worst {:.1}ms\n build_p50 {:.1}ms acquire_p50 \
{:.1}ms submit_p50 {:.1}ms",
stats.total_frames, stats.total_frames,
total_seconds, total_seconds,
refresh_hz, refresh_hz,
@@ -682,6 +594,7 @@ impl BenchClient {
stats.p99.as_secs_f64() * 1000.0, stats.p99.as_secs_f64() * 1000.0,
stats.worst.as_secs_f64() * 1000.0, stats.worst.as_secs_f64() * 1000.0,
stats.cpu_p50.as_secs_f64() * 1000.0, stats.cpu_p50.as_secs_f64() * 1000.0,
stats.acquire_p50.as_secs_f64() * 1000.0,
stats.gpu_wait_p50.as_secs_f64() * 1000.0, stats.gpu_wait_p50.as_secs_f64() * 1000.0,
) )
} }
@@ -700,9 +613,11 @@ impl BenchClient {
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms", " type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
TYPE_TEXT.chars().count() TYPE_TEXT.chars().count()
); );
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
let report = format!( let report = format!(
"iris bench report\n{per_phase}{frames_block}\n\nbench:\n{fling_line}\n\ "iris bench report\n{traced}\n{per_phase}{frames_block}\n\nbench:\n\
{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n{rss_line}\n{battery}" {fling_line}\n{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n\
{rss_line}\n{battery}"
); );
log::info!("iris bench report: {report}"); log::info!("iris bench report: {report}");
state.report_display.edit(rsc).set(&report); state.report_display.edit(rsc).set(&report);
@@ -727,7 +642,7 @@ impl BenchClient {
/// drained everything queued before this call existed. Cost a real hang /// drained everything queued before this call existed. Cost a real hang
/// in this file's first version of the fling phase: every loop iteration /// in this file's first version of the fling phase: every loop iteration
/// after the first sat forever with nothing scheduled to drain it. /// after the first sat forever with nothing scheduled to drain it.
/// Polls rather than assuming one `ANIM_STEP_MS` sleep is enough, since a /// Polls rather than assuming one `POLL_MS` sleep is enough, since a
/// slow device's frame callback can lag further than that. /// slow device's frame callback can lag further than that.
async fn read_from_state<T, F>( async fn read_from_state<T, F>(
ctx: &mut iris::task::TaskCtx<Rsc>, ctx: &mut iris::task::TaskCtx<Rsc>,
@@ -747,19 +662,10 @@ where
if let Ok(value) = rx.try_recv() { if let Ok(value) = rx.try_recv() {
return value; return value;
} }
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await; tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
} }
} }
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
/// from it (toward older messages) through `List::fling`, then
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
/// convention (`List::scroll`'s own doc: positive moves *later* content
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
/// since `TranscriptList`'s `LazyColumn` and this list define "positive"
/// the other way around; the two apps' *travel* is still directly
/// comparable because both report it as a row index + pixel offset, not a
/// signed distance.
async fn run_fling_phase( async fn run_fling_phase(
ctx: &mut iris::task::TaskCtx<Rsc>, ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>, redraw: &Arc<dyn RequestRedraw>,
@@ -775,13 +681,14 @@ async fn run_fling_phase(
redraw.request_redraw(); redraw.request_redraw();
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s // Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
// `anchor = None` into a real slot before `start` is read. // `anchor = None` into a real slot before `start` is read.
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS * 2)).await; tokio::time::sleep(Duration::from_millis(POLL_MS * 2)).await;
let start = read_anchor_position(ctx, redraw).await; let start = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT { for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| { ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen { if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S); (screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
animate_scroll(screen.list, rsc);
} }
}); });
redraw.request_redraw(); redraw.request_redraw();
@@ -793,7 +700,8 @@ async fn run_fling_phase(
for _ in 0..FLING_COUNT { for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| { ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen { if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S); (screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
animate_scroll(screen.list, rsc);
} }
}); });
redraw.request_redraw(); redraw.request_redraw();
@@ -802,7 +710,7 @@ async fn run_fling_phase(
} }
let end = read_anchor_position(ctx, redraw).await; let end = read_anchor_position(ctx, redraw).await;
format!("start={start} outward={outward} end={end}") format!("start={start} outward={outward} end={end} ticked=frame-loop")
} }
async fn read_anchor_position( async fn read_anchor_position(
@@ -816,11 +724,11 @@ async fn read_anchor_position(
.await .await
} }
/// Ticks the fling forward in ~60Hz steps (the same shape fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
/// `run_stream_phase`'s per-event loop and the old `animate_scroll` used) let id = scroll.id();
/// until it settles or `FLING_SETTLE_CAP_MS` passes -- belt-and-suspenders rsc.ui_mut().animate(id);
/// the same way `BenchRun.kt`'s own `waitForSettle` is, since a fling's }
/// own spline-decided `duration()` already caps how long it can run.
async fn wait_for_fling_settle( async fn wait_for_fling_settle(
ctx: &mut iris::task::TaskCtx<Rsc>, ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>, redraw: &Arc<dyn RequestRedraw>,
@@ -829,22 +737,17 @@ async fn wait_for_fling_settle(
let started = Instant::now(); let started = Instant::now();
while started.elapsed() < cap { while started.elapsed() < cap {
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen { let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).tick_fling(Instant::now()), Some(screen) => (screen.list)(rsc).is_scrolling(),
None => false, None => false,
}) })
.await; .await;
if !still_scrolling { if !still_scrolling {
return; return;
} }
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await; tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
} }
} }
/// Phase 2, unchanged from v1: pinned to the newest end before streaming
/// starts (matching `stream-bench.sh`'s "Jump to latest" tap), then
/// `STREAM_EVENTS_PER_SEC * STREAM_SECONDS` fixture events replayed
/// through the real `fold_event`/`TranscriptScreen::apply` path. Returns
/// `(sent, total)`.
async fn run_stream_phase( async fn run_stream_phase(
ctx: &mut iris::task::TaskCtx<Rsc>, ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>, redraw: &Arc<dyn RequestRedraw>,
@@ -875,17 +778,10 @@ async fn run_stream_phase(
sent += 1; sent += 1;
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await; tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
} }
// Lets the last few deltas land and draw before the next phase starts
// -- `BenchRun.kt`'s own closing delay.
tokio::time::sleep(Duration::from_millis(300)).await; tokio::time::sleep(Duration::from_millis(300)).await;
(sent, total) (sent, total)
} }
/// Phase 3: focuses the real composer, shows the keyboard, then types
/// `TYPE_TEXT` one character at a time through the composer `TextEdit`'s
/// real edit path (`set`, the same call a real keystroke's `onValueChange`
/// makes -- `Composer::build_composer`'s `field`), and deletes it the same
/// way.
async fn run_type_phase( async fn run_type_phase(
ctx: &mut iris::task::TaskCtx<Rsc>, ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>, redraw: &Arc<dyn RequestRedraw>,
@@ -904,9 +800,6 @@ async fn run_type_phase(
if let Some(p) = platform { if let Some(p) = platform {
p.show_ime(); p.show_ime();
} }
// Lets focus and the keyboard's opening animation land before typing
// starts, so the frames this phase records are the wrap/reflow it is
// measuring, not the keyboard opening -- `BenchRun.kt`'s own delay.
tokio::time::sleep(Duration::from_millis(300)).await; tokio::time::sleep(Duration::from_millis(300)).await;
let mut typed = String::new(); let mut typed = String::new();
@@ -935,13 +828,6 @@ async fn run_type_phase(
} }
} }
/// Phase 4: `KEYBOARD_CYCLES` show/hide cycles through the shell's own
/// `InputMethodManager` (`bench_jni.rs`'s `show_ime`/`hide_ime`), each
/// confirmed by `on_insets_changed`'s real `ime_bottom` transition rather
/// than assumed from the JNI call having returned -- `ImeState`'s doc.
/// "keyboard: could not be shown" if the platform never confirms it even
/// once, per UI_RULES.md ("design the unknown/failed state before the
/// answer's").
async fn run_keyboard_phase( async fn run_keyboard_phase(
ctx: &mut iris::task::TaskCtx<Rsc>, ctx: &mut iris::task::TaskCtx<Rsc>,
platform: &Option<Arc<PlatformHandle>>, platform: &Option<Arc<PlatformHandle>>,
@@ -988,10 +874,6 @@ async fn run_keyboard_phase(
mod tests { mod tests {
use super::TYPE_TEXT; use super::TYPE_TEXT;
/// `BenchRun.kt`'s own `TYPE_TEXT` is verified `.length == 600`; this
/// is the same string, so it has to match exactly or the two apps'
/// type phases stop typing the same content -- RUST.md's "Benchmark
/// v2" spec is one shared string for both.
#[test] #[test]
fn type_text_is_exactly_600_characters() { fn type_text_is_exactly_600_characters() {
assert_eq!(TYPE_TEXT.chars().count(), 600); assert_eq!(TYPE_TEXT.chars().count(), 600);
@@ -1,24 +1,3 @@
//! JNI calls the `bench` feature needs that go through the shell's own
//! Java side rather than anything `iris`/`android-view` already wraps:
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
//! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
//! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
//! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
//! report's real late-frame budget and `InputMethodManager.
//! showSoftInput`/`hideSoftInputFromWindow` for the keyboard phase. None
//! of these are part of `android_view::context`'s own `Context`/
//! `Resources` wrappers (that file's own `// TODO: more methods?`), so
//! this calls them directly rather than growing that crate's wrapper for
//! calls this crate alone needs.
//!
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach
//! whichever thread calls it -- the battery sampler runs on a background
//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls
//! run on. `JavaVM::attach_current_thread` is safe to call from a thread
//! already attached (the `jni` crate detects it and does not double
//! attach), so no caller here needs to know or care which thread it is.
use android_view::jni::{ use android_view::jni::{
JNIEnv, JavaVM, JNIEnv, JavaVM,
objects::{GlobalRef, JObject, JValue}, objects::{GlobalRef, JObject, JValue},
@@ -69,13 +48,6 @@ impl PlatformHandle {
.ok() .ok()
} }
/// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None`
/// on any JNI failure, on a device with no `BatteryManager` service,
/// or when the platform itself answers "not supported" -- `0` or
/// `Integer.MIN_VALUE` are both documented SDK answers for that, and
/// both would read as a real (and wrong) measurement if folded into an
/// average rather than named apart. UI_RULES.md: never present an
/// inferred value as a measured one.
pub fn battery_current_ua(&self) -> Option<i32> { pub fn battery_current_ua(&self) -> Option<i32> {
let mut guard = self.vm.attach_current_thread().ok()?; let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard; let env: &mut JNIEnv = &mut guard;
@@ -135,14 +107,6 @@ impl PlatformHandle {
Some(()) Some(())
} }
/// The display's own refresh rate in Hz (`View::getDisplay()` ->
/// `Display::getRefreshRate()`), for RUST.md's "Benchmark v2": late
/// frames are judged against *this* device's real budget, not an
/// assumed 60Hz -- a 90Hz or 120Hz phone would otherwise call frames
/// "late" that met their own faster deadline. `None` if the view is
/// not yet attached to a window (`getDisplay` returns `null`) or the
/// platform reports a non-positive rate, which is not a real answer
/// either.
pub fn refresh_rate_hz(&self) -> Option<f32> { pub fn refresh_rate_hz(&self) -> Option<f32> {
let mut guard = self.vm.attach_current_thread().ok()?; let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard; let env: &mut JNIEnv = &mut guard;
@@ -167,20 +131,10 @@ impl PlatformHandle {
if rate > 0.0 { Some(rate) } else { None } if rate > 0.0 { Some(rate) } else { None }
} }
/// `InputMethodManager.showSoftInput(view, 0)` -- the keyboard phase's
/// own show, called directly rather than through the focus-driven
/// `pending_show_keyboard` path `android/view.rs` uses for a real tap,
/// since RUST.md's "Benchmark v2" spec asks for this "through the
/// shell's InputMethodManager" independent of focus state. `true` only
/// if the platform itself reports the request succeeded -- whether the
/// IME actually became visible is confirmed separately, from
/// `on_insets_changed`, per UI_RULES.md ("never present an inferred
/// value as a measured one").
pub fn show_ime(&self) -> bool { pub fn show_ime(&self) -> bool {
self.try_toggle_ime(true).unwrap_or(false) self.try_toggle_ime(true).unwrap_or(false)
} }
/// `InputMethodManager.hideSoftInputFromWindow(windowToken, 0)`.
pub fn hide_ime(&self) -> bool { pub fn hide_ime(&self) -> bool {
self.try_toggle_ime(false).unwrap_or(false) self.try_toggle_ime(false).unwrap_or(false)
} }
@@ -222,31 +176,4 @@ impl PlatformHandle {
.ok() .ok()
} }
} }
/// Shows `report` in the shell's plain-view diagnostics overlay
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
/// and Close controls, added over whatever iris itself is drawing
/// rather than replacing it (unlike `android::view::show_renderer_error`,
/// which exists for the case the renderer can never recover from and
/// intentionally never returns). Called from a background task after
/// the keyboard-open delay (`bench_client.rs`'s `on_insets_changed`),
/// so the Java side hops onto the UI thread itself before touching the
/// view tree -- see that method's own comment.
pub fn show_diagnostics_overlay(&self, report: &str) -> bool {
self.try_show_diagnostics_overlay(report).is_some()
}
fn try_show_diagnostics_overlay(&self, report: &str) -> Option<()> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let jreport = env.new_string(report).ok()?;
env.call_method(
self.view.as_obj(),
"showDiagnosticsOverlay",
"(Ljava/lang/String;)V",
&[JValue::Object(jreport.as_ref())],
)
.ok()?;
Some(())
}
} }
+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,55 +1,12 @@
//! RUST.md's I5 Android integration: `transcript-ui`'s screen filling the use crate::client::api::{ApiClient, UreqTransport};
//! whole window on android-view, against a real `ai-server` through use crate::client::event_stream::{StreamItem, follow_session_events};
//! `client-core` -- the missing half `iris-android-app` (I2) only had for use crate::client::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
//! `tabs-ui` until now. Behind the `transcript-screen` Cargo feature so the
//! plain build (`cargo ndk build`, no `--features`) stays exactly the tabs
//! demo I2/I4 already measured against.
//!
//! **Deliberate simplification, recorded rather than left to be
//! rediscovered (RUST.md's I5 box has the full account)**: there is no
//! session list and no enrollment UI here. The server, port, token and
//! pinned CA are baked in at build time (`build.rs`'s
//! `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA`), and the first
//! session `ApiClient::fetch_sessions` returns is opened automatically --
//! there is nothing to tap to get there, which is what `transcript-bench.sh`
//! and `ui-trace` need to land straight on the screen under test. A real
//! app needs `desktop-app`'s `EnrolledServer`/QR-link flow or E3's
//! Keystore-sealed `ServerConfig.kt`; building a second one of those was
//! not this pass's job.
//!
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
//! `client_core::transcript_fold`, a `generation` counter guarding against
//! a stale background response. What differs is only the redraw
//! mechanism: android-view has no `winit::EventLoopProxy`, so this uses
//! `iris::task::Tasks::redraw_handle` (new, added alongside this box) to
//! request a frame after each `TaskCtx::update` instead of relying on
//! `Tasks::spawn`'s single end-of-future redraw -- see that method's own
//! doc for why.
//!
//! **Streaming no longer costs a full rebuild** (fixed after the P0 gate
//! showed why it mattered -- 20 events/second means 20 rebuilds/second of
//! a ~3,200-row transcript otherwise): `apply_event` calls
//! `transcript_ui::TranscriptScreen::apply` with the item list before and
//! after `fold_event`, which updates only the row(s) that actually
//! changed (almost always just the one open assistant message) instead of
//! refolding and rebuilding every row. `rebuild_transcript` still runs
//! the whole widget tree once, for the opening page and for `apply`'s own
//! rare regroup fallback.
use client_core::api::{ApiClient, UreqTransport};
use client_core::event_stream::{StreamItem, follow_session_events};
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
use event_model::SeqEvent; use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*; use iris::prelude::*;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
mod pinned {
include!(concat!(env!("OUT_DIR"), "/pinned_config.rs"));
}
pub struct TranscriptClient { pub struct TranscriptClient {
ui_state: AndroidUiState, ui_state: AndroidUiState,
/// The screen's own content -- everything under the fixed /// The screen's own content -- everything under the fixed
@@ -58,21 +15,9 @@ pub struct TranscriptClient {
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched /// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
/// by rebuilding the session list beside it. /// by rebuilding the session list beside it.
content: WeakWidget<WidgetPtr>, content: WeakWidget<WidgetPtr>,
screen: Option<transcript_ui::TranscriptScreen>, screen: Option<crate::ui::TranscriptScreen>,
/// The folded transcript as of the last rebuild -- kept here (not
/// re-derived) for the same reason `desktop-app`'s `Client::items`
/// exists: a live `StreamEvent` only carries one new wire event, and
/// `fold_event` needs everything folded so far to fold it in.
items: Vec<TranscriptItem>, items: Vec<TranscriptItem>,
/// The session currently open -- `None` only before the first fetch
/// resolves. Read back by `apply_event`'s rebuild, which has no session
/// id of its own (a live `SeqEvent` doesn't carry one).
session_id: Option<String>, session_id: Option<String>,
/// Bumped every time a new session load starts; a background response
/// checks it before touching state, so a slow reply for a session this
/// screen has moved on from can't overwrite what replaced it. There is
/// only ever one session here (no list to switch away to), but the
/// guard still matters for the *first* fetch racing a `stop`/`start`.
generation: Arc<AtomicU64>, generation: Arc<AtomicU64>,
} }
@@ -85,41 +30,22 @@ impl HasAndroidUiState for TranscriptClient {
} }
} }
/// Builds one `UreqTransport` from the config `build.rs` baked in. Called
/// twice per session load, same as `desktop-app`'s `build_transport`
/// closure -- `ApiClient` and the live-stream follow each need their own,
/// since `UreqTransport` holds its own `ureq::Agent`.
fn build_transport() -> Result<UreqTransport, String> { fn build_transport() -> Result<UreqTransport, String> {
let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT); crate::android::enrollment::transport()
UreqTransport::new(
base_url,
pinned::TOKEN.to_string(),
pinned::CA_PEM.as_bytes(),
)
.map_err(|e| e.to_string())
} }
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget { fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string()) wtext(message.to_string())
.color(Color::WHITE) .color(PaintId::WHITE)
.wrap(true) .wrap(true)
.pad(16) .pad(16)
.add_strong(rsc) .add_strong(rsc)
.any() .any()
} }
/// The two named controls RUST.md's I5 box ("Measurements taken" (b))
/// drives by name over `ui-trace`, e.g. `ui-trace record --do "tap 'Frame
/// report'"`. `dumpsys gfxinfo` cannot see this screen's own GPU-drawn
/// frames at all -- this is the screen's own equivalent of the Compose
/// app's "Copy render timings" control, logged rather than clipboarded
/// (no clipboard wiring exists here) under this crate's own fixed
/// `android_logger` tag (`iris-android-app`, `lib.rs`'s `JNI_OnLoad`),
/// grep-able on the fixed string `"iris frame report"` the way
/// `transcript-bench.sh` greps `"ai-app render report"`.
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget { fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
type Rsc = AndroidRsc<TranscriptClient>; type Rsc = AndroidRsc<TranscriptClient>;
let report_rect = rect(Color::rgb(50, 50, 60)) let report_rect = rect(Srgba8::rgb(50, 50, 60))
.on( .on(
CursorSense::click(), CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx |ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
@@ -143,7 +69,7 @@ fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
.pad(8) .pad(8)
.add(rsc); .add(rsc);
let reset_rect = rect(Color::rgb(70, 40, 40)) let reset_rect = rect(Srgba8::rgb(70, 40, 40))
.on( .on(
CursorSense::click(), CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| { |ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
@@ -173,7 +99,7 @@ impl AndroidAppState for TranscriptClient {
.span(Dir::DOWN) .span(Dir::DOWN)
.add_strong(rsc) .add_strong(rsc)
.any(); .any();
ui_state.set_root(tree); ui_state.set_root(rsc, tree);
let mut client = Self { let mut client = Self {
ui_state, ui_state,
@@ -230,10 +156,6 @@ impl TranscriptClient {
}); });
} }
/// Loads the opening page, then follows the live SSE stream for the
/// rest of this session's life -- `desktop-app`'s `select_session`
/// almost verbatim, with `Proxy::send_event` replaced by `ctx.update` +
/// `redraw.request_redraw()` (see this module's doc).
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) { fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
self.items.clear(); self.items.clear();
@@ -260,22 +182,14 @@ impl TranscriptClient {
}; };
let api = ApiClient::new(rest); let api = ApiClient::new(rest);
// The most recent 200 events, coalesced -- the same page size
// `desktop-app` uses; RUST.md's I3/history-paging work is what
// a real scrollback would reuse (out of scope here, same as
// E4).
let page: Result<Vec<serde_json::Value>, String> = api let page: Result<Vec<serde_json::Value>, String> = api
.fetch_transcript_page(&session_id, None, 200, true) .fetch_transcript_page(&session_id, None, 200, true)
.map_err(|e| e.to_string()); .map_err(|e| e.to_string());
// The wire `seq` of the last line, not a folded item's `seq()`
// -- see `client_core::transcript_fold::raw_seq`'s doc for why
// resuming from the latter re-delivers deltas already folded
// into an in-progress reply.
let after = page let after = page
.as_ref() .as_ref()
.ok() .ok()
.and_then(|values| values.last()) .and_then(|values| values.last())
.and_then(client_core::transcript_fold::raw_seq) .and_then(crate::client::transcript_fold::raw_seq)
.unwrap_or(0); .unwrap_or(0);
let result = page.and_then(|values| fold_page(&values)); let result = page.and_then(|values| fold_page(&values));
@@ -301,12 +215,6 @@ impl TranscriptClient {
if live_generation.load(Ordering::SeqCst) != my_generation { if live_generation.load(Ordering::SeqCst) != my_generation {
return; return;
} }
// The outer closure here is an `FnMut` -- `follow_session_events`
// calls it once per line -- so it captures `live_generation` by
// move and re-clones it for each inner `ctx.update` closure
// rather than moving a shared `stop`-style helper into itself:
// a value moved out of an `FnMut`'s captures on one call leaves
// nothing there for the next.
let _ = let _ =
follow_session_events( follow_session_events(
&stream_transport, &stream_transport,
@@ -335,10 +243,6 @@ impl TranscriptClient {
}); });
} }
/// Rebuilds the whole widget tree from `self.items` -- same tradeoff as
/// `desktop-app`'s `rebuild_transcript` (this module's doc comment).
/// Reads `self.session_id` rather than taking one, since every caller
/// (the opening page, and every live event) already has it set there.
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) { fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
let in_progress = self let in_progress = self
.screen .screen
@@ -347,7 +251,7 @@ impl TranscriptClient {
.filter(|t| !t.is_empty()); .filter(|t| !t.is_empty());
let rows = group_tool_runs(&self.items); let rows = group_tool_runs(&self.items);
let (screen, tree) = transcript_ui::build_tree(rsc, rows); let (screen, tree) = crate::ui::build_tree(rsc, rows);
if let Some(text) = in_progress { if let Some(text) = in_progress {
screen.composer.field.edit(rsc).set(&text); screen.composer.field.edit(rsc).set(&text);
@@ -371,12 +275,7 @@ impl TranscriptClient {
let old_items = self.items.clone(); let old_items = self.items.clone();
self.items = fold_event(&self.items, event); self.items = fold_event(&self.items, event);
match &self.screen { match &self.screen {
// The common path: update only the row(s) that actually
// changed instead of refolding and rebuilding all ~3,200 of
// them per event (RUST.md's P0 streaming-phase fix).
Some(screen) => screen.apply(rsc, &old_items, &self.items), Some(screen) => screen.apply(rsc, &old_items, &self.items),
// No screen yet (the opening page hasn't landed) -- build one
// the ordinary way once it has.
None => self.rebuild_transcript(rsc), None => self.rebuild_transcript(rsc),
} }
} }
+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; use std::ops::Range;
/// An RGB colour, the same shape wherever this crate names one -- no alpha, /// An RGB colour, the same shape wherever this crate names one -- no alpha,
@@ -38,18 +16,10 @@ impl Rgb {
} }
} }
/// The sixteen colours a terminal program names, and the two it assumes.
///
/// Its own palette rather than the syntax one: a program that prints in red
/// has chosen red, where a highlighter's colours are this app's reading of
/// somebody else's code.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AnsiPalette { pub struct AnsiPalette {
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
pub colours: [Rgb; 16], pub colours: [Rgb; 16],
/// What uncoloured text is, needed only where a style has to state a colour.
pub foreground: Rgb, pub foreground: Rgb,
/// What the text sits on, needed for reverse video.
pub background: Rgb, pub background: Rgb,
} }
@@ -67,8 +37,6 @@ pub struct Style {
pub strikethrough: bool, pub strikethrough: bool,
} }
/// Plain text plus the non-overlapping, ordered spans that style parts of it
/// -- this crate's stand-in for Compose's `AnnotatedString`.
#[derive(Debug, Clone, PartialEq, Default)] #[derive(Debug, Clone, PartialEq, Default)]
pub struct StyledText { pub struct StyledText {
pub text: String, pub text: String,
@@ -87,11 +55,7 @@ impl StyledText {
const ESC: char = '\u{1B}'; const ESC: char = '\u{1B}';
const BELL: char = '\u{7}'; const BELL: char = '\u{7}';
/// [text] with its terminal styling applied and everything else taken out;
/// see the module doc.
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText { pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
// The common case by a long way -- nothing to do, and nothing allocated
// to find that out.
if !text.contains(ESC) && !text.contains('\r') { if !text.contains(ESC) && !text.contains('\r') {
return StyledText::plain(text.to_string()); return StyledText::plain(text.to_string());
} }
@@ -118,19 +82,12 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
} }
}); });
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') { } else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
// A bare carriage return rewrites the line. One before a newline
// is the other half of a Windows line ending: it rewrites
// nothing, and it is dropped rather than kept, since that pair
// is one line break.
flush(&mut plain, sgr, &mut runs); flush(&mut plain, sgr, &mut runs);
drop_line(&mut runs); drop_line(&mut runs);
at += 1; at += 1;
} else if c == '\r' { } else if c == '\r' {
at += 1; at += 1;
} else if c >= ' ' || c == '\n' || c == '\t' { } else if c >= ' ' || c == '\n' || c == '\t' {
// Everything printable, plus the two control characters that are
// layout rather than terminal commands. A stray bell or
// backspace goes for the same reason a cursor move does.
plain.push(c); plain.push(c);
at += 1; at += 1;
} else { } else {
@@ -151,8 +108,6 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
StyledText { text: out, spans } StyledText { text: out, spans }
} }
/// Throws away everything written since the last line break, as a carriage
/// return does.
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) { fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
while let Some((text, style)) = runs.pop() { while let Some((text, style)) = runs.pop() {
if let Some(break_at) = text.rfind('\n') { if let Some(break_at) = text.rfind('\n') {
@@ -162,7 +117,6 @@ fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
} }
} }
/// The bytes that end a CSI sequence.
fn is_csi_final(c: char) -> bool { fn is_csi_final(c: char) -> bool {
('@'..='~').contains(&c) ('@'..='~').contains(&c)
} }
@@ -184,10 +138,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
end += 1; end += 1;
} }
if end >= chars.len() { if end >= chars.len() {
// Cut off mid-sequence, which is what a stream that has not
// finished arriving looks like: drop the fragment rather
// than printing it, and the whole sequence arrives with the
// next delta.
chars.len() chars.len()
} else { } else {
let params: String = chars[at + 2..end].iter().collect(); let params: String = chars[at + 2..end].iter().collect();
@@ -196,8 +146,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
} }
} }
']' | 'P' | 'X' | '^' | '_' => { ']' | 'P' | 'X' | '^' | '_' => {
// Runs to a string terminator: `ESC \`, or the bell that xterm
// allows after an OSC.
let mut end = at + 2; let mut end = at + 2;
while end < chars.len() { while end < chars.len() {
if chars[end] == BELL { if chars[end] == BELL {
@@ -214,7 +162,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
} }
} }
/// Everything an SGR sequence can turn on, as the terminal tracks it.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
struct Sgr { struct Sgr {
fg: Option<Rgb>, fg: Option<Rgb>,
@@ -227,7 +174,6 @@ struct Sgr {
reverse: bool, reverse: bool,
} }
/// How much of its colour dim text keeps: enough to read, little enough to recede.
const DIM_ALPHA: f32 = 0.65; const DIM_ALPHA: f32 = 0.65;
impl Sgr { impl Sgr {
@@ -242,7 +188,6 @@ impl Sgr {
reverse: false, reverse: false,
}; };
/// `None` while nothing is set, so unstyled output costs no spans at all.
fn span(&self, palette: &AnsiPalette) -> Option<Style> { fn span(&self, palette: &AnsiPalette) -> Option<Style> {
if *self == Sgr::PLAIN { if *self == Sgr::PLAIN {
return None; return None;
@@ -275,15 +220,7 @@ impl Sgr {
}) })
} }
/// This state with `params` applied -- one `ESC[...m`, which carries any
/// number of them.
///
/// A code this does not model is ignored rather than reset from: the
/// program meant something by it, and starting again would also drop
/// the codes beside it that are understood.
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr { fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
// zero too.
let codes: Vec<i64> = params let codes: Vec<i64> = params
.split(';') .split(';')
.map(|p| p.trim().parse::<i64>().unwrap_or(0)) .map(|p| p.trim().parse::<i64>().unwrap_or(0))
@@ -377,12 +314,6 @@ impl Sgr {
} }
} }
/// The colour named by a `38`/`48` at `at`, and the index of that colour's
/// last parameter.
///
/// Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal
/// one. The first sixteen of that table are the palette's own, so a program
/// asking for "colour 1" through either spelling gets the same red.
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) { fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
match codes.get(at + 1) { match codes.get(at + 1) {
Some(&5) => match codes.get(at + 2) { Some(&5) => match codes.get(at + 2) {
@@ -409,11 +340,8 @@ fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<R
} }
} }
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255]; const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
/// grey ramp.
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb { fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
if n < 0 { if n < 0 {
palette.foreground palette.foreground
@@ -434,8 +362,6 @@ fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
mod tests { mod tests {
use super::*; use super::*;
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
/// white foreground, black background.
fn palette() -> AnsiPalette { fn palette() -> AnsiPalette {
let mut colours = [Rgb::new(0, 0, 0); 16]; let mut colours = [Rgb::new(0, 0, 0); 16];
for (i, c) in colours.iter_mut().enumerate() { for (i, c) in colours.iter_mut().enumerate() {
@@ -452,8 +378,6 @@ mod tests {
ansi_styled(text, &palette()) ansi_styled(text, &palette())
} }
/// The style covering the first character of `word`, or `None` where
/// nothing styles it.
fn style_over(text: &str, word: &str) -> Option<Style> { fn style_over(text: &str, word: &str) -> Option<Style> {
let out = styled(text); let out = styled(text);
let at = out let at = out
@@ -509,8 +433,6 @@ mod tests {
#[test] #[test]
fn everything_that_is_not_styling_is_dropped_rather_than_printed() { fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
// A cursor move, an erase, an OSC window title with its bell, and a
// bare two-character escape.
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e"); let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
assert_eq!(styled(&text).text, "abcde"); assert_eq!(styled(&text).text, "abcde");
} }
@@ -1,22 +1,9 @@
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
//! module doc for the table); the SSE half is [`crate::event_stream`].
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
//! which do not.
//!
//! Network I/O sits behind the [`Transport`] trait so the rest of this
//! crate, and anything built on it, can be tested against a fake one with
//! no server involved. [`UreqTransport`] is the only real implementation.
use std::io::Read; use std::io::Read;
use event_model::SeqEvent; use event_model::SeqEvent;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
/// A request that did not produce what it asked for, carrying the server's
/// own wording where it sent some.
///
/// `status` is the HTTP status where there was a response at all, and /// `status` is the HTTP status where there was a response at all, and
/// `None` where the server was never reached -- mirroring `ApiException` in /// `None` where the server was never reached -- mirroring `ApiException` in
/// `Api.kt`. /// `Api.kt`.
@@ -33,8 +20,6 @@ impl std::fmt::Display for ApiError {
} }
impl std::error::Error for ApiError {} impl std::error::Error for ApiError {}
/// A request body to send, in whichever of the two shapes the surface
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
pub enum Body { pub enum Body {
Json(Value), Json(Value),
Bytes { Bytes {
@@ -51,10 +36,7 @@ pub struct RawResponse {
pub body: Vec<u8>, pub body: Vec<u8>,
} }
/// The network boundary this crate's pure logic is kept out from behind.
/// `server/src/routes.rs`'s module doc is the surface this drives.
pub trait Transport: Send + Sync { pub trait Transport: Send + Sync {
/// One request/response call -- everything but the long-lived SSE GETs.
fn request( fn request(
&self, &self,
method: &str, method: &str,
@@ -62,10 +44,6 @@ pub trait Transport: Send + Sync {
body: Option<Body>, body: Option<Body>,
) -> Result<RawResponse, ApiError>; ) -> Result<RawResponse, ApiError>;
/// Opens `path` and answers a reader over the response body, for a
/// caller that reads it as a stream rather than all at once (the SSE
/// connections in [`crate::event_stream`]). Fails the same way
/// [`Transport::request`] does for a non-2xx response.
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>; fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
} }
@@ -104,10 +82,6 @@ fn default_true() -> bool {
true true
} }
/// A client-core equivalent of `requestFromServer` plus the typed calls
/// built on it. Holds no state of its own beyond the transport -- the
/// session id or setup id a call is about is a parameter, per this
/// project's "ask for the least you need".
pub struct ApiClient<T: Transport> { pub struct ApiClient<T: Transport> {
transport: T, transport: T,
} }
@@ -265,7 +239,7 @@ impl<T: Transport> ApiClient<T> {
/// A page of transcript history. `before` is the newest-first cursor /// A page of transcript history. `before` is the newest-first cursor
/// (server default is "the newest page" when absent, which a caller /// (server default is "the newest page" when absent, which a caller
/// gets by passing `None`); the events themselves are handed back as /// gets by passing `None`); the events themselves are handed back as
/// [`event_model::SeqEvent`] via `crate::event_stream`'s parsing, kept /// [`event_model::SeqEvent`] via `crate::client::event_stream`'s parsing, kept
/// out of this method's signature so a caller that only wants the raw /// out of this method's signature so a caller that only wants the raw
/// lines (for the transcript cache) is not forced to parse them. /// lines (for the transcript cache) is not forced to parse them.
pub fn fetch_transcript_page( pub fn fetch_transcript_page(
@@ -282,19 +256,6 @@ impl<T: Transport> ApiClient<T> {
) )
} }
/// A page of transcript history, each line handed back paired with the
/// exact text it came from, and bounded below by `after` -- the shape
/// `crate::transcript_source::TranscriptSource` needs to store what it
/// fetched in the transcript cache without a second round trip to fetch
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
///
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
/// parsed [`Value`], so the stored line is the exact bytes the server
/// sent (key order and float literal included) rather than this
/// crate's own idea of how to write them back out -- the cache and a
/// live SSE frame must agree byte-for-byte on the same event, which is
/// exactly what caught the `serde_json` float-rounding bug this
/// project's `AGENTS.md` records.
pub fn fetch_transcript_lines( pub fn fetch_transcript_lines(
&self, &self,
session_id: &str, session_id: &str,
@@ -320,9 +281,6 @@ impl<T: Transport> ApiClient<T> {
} }
} }
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
/// parameter is written rather than keeping two copies to drift.
fn transcript_path( fn transcript_path(
session_id: &str, session_id: &str,
before: Option<u64>, before: Option<u64>,
@@ -343,12 +301,6 @@ fn transcript_path(
path path
} }
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
/// poll). Verifies the server's leaf against a single pinned CA, the way
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
/// store -- the server's certificate is self-signed on purpose (see
/// `wg-app-link`).
pub struct UreqTransport { pub struct UreqTransport {
agent: ureq::Agent, agent: ureq::Agent,
base_url: String, base_url: String,
@@ -356,8 +308,6 @@ pub struct UreqTransport {
} }
impl UreqTransport { impl UreqTransport {
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
/// exactly as read from `certs/ca.pem`.
pub fn new( pub fn new(
base_url: impl Into<String>, base_url: impl Into<String>,
token: impl Into<String>, token: impl Into<String>,
@@ -372,10 +322,6 @@ impl UreqTransport {
.build(); .build();
let agent: ureq::Agent = ureq::Agent::config_builder() let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls_config) .tls_config(tls_config)
// Read the body ourselves on every status, the way
// `requestFromServer` does: the server's own error wording is
// in the body of a 4xx/5xx, and the default behaviour throws
// it away before this code can read it.
.http_status_as_error(false) .http_status_as_error(false)
.timeout_connect(Some(std::time::Duration::from_secs(5))) .timeout_connect(Some(std::time::Duration::from_secs(5)))
.build() .build()
@@ -453,9 +399,6 @@ impl Transport for UreqTransport {
.get(&url) .get(&url)
.header("Authorization", &auth) .header("Authorization", &auth)
.header("Accept", "text/event-stream") .header("Accept", "text/event-stream")
// No read timeout: between events there is nothing to read for
// as long as the thing being followed is idle, mirroring
// `EventStream.kt`'s `readTimeout = 0`.
.config() .config()
.timeout_recv_response(None) .timeout_recv_response(None)
.build() .build()
@@ -481,9 +424,6 @@ fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
} }
} }
/// The 401 wording matches `Api.kt`'s, since that message is instructions
/// for the reader rather than a diagnostic -- see this project's UI rule
/// about shortening a failure in one place rather than at each display site.
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError { fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
let detail = String::from_utf8_lossy(body).trim().to_string(); let detail = String::from_utf8_lossy(body).trim().to_string();
let message = if status == 401 { let message = if status == 401 {
@@ -507,8 +447,6 @@ mod tests {
use std::io::Cursor; use std::io::Cursor;
use std::sync::Mutex; use std::sync::Mutex;
/// A transport with no network at all, for the pure-logic tests this
/// module can run without a server.
#[derive(Default)] #[derive(Default)]
struct FakeTransport { struct FakeTransport {
responses: Mutex<Vec<(String, String, RawResponse)>>, responses: Mutex<Vec<(String, String, RawResponse)>>,
@@ -569,7 +507,6 @@ mod tests {
assert_eq!(sessions.len(), 1); assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s1"); assert_eq!(sessions[0].id, "s1");
assert_eq!(sessions[0].setup_name, "desktop"); assert_eq!(sessions[0].setup_name, "desktop");
// Defaults for fields the server omits.
assert!(sessions[0].notify); assert!(sessions[0].notify);
assert_eq!(sessions[0].model, None); assert_eq!(sessions[0].model, None);
} }
+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}"
);
}
}
@@ -1,12 +1,3 @@
//! A span of milliseconds, written the way somebody reads it -- the port
//! of `Durations.kt`'s `formatMillis`/`formatMillisText`, with its tests.
//!
//! Only the tool-timeout half is here. `formatSpan` (the usage
//! countdown's rounding-up rule) belongs with whatever draws the usage
//! bar, and nothing in this crate needs it yet.
/// A span of milliseconds, written the way somebody reads it.
///
/// A tool's timeout arrives as `480000`, which nobody reads as eight /// 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 /// 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 /// are read for different things. Under a minute the question is "roughly
@@ -14,9 +5,6 @@
/// rest -- `2.5s`. At a minute or more the question is "how long exactly", /// 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 /// so every unit with something in it is written out -- `5d 12h 4m`. Empty
/// units are left out rather than written as zero. /// units are left out rather than written as zero.
///
/// Sub-second precision is dropped past a minute: nothing that takes days
/// is measured in milliseconds.
pub fn format_millis(ms: i64) -> String { pub fn format_millis(ms: i64) -> String {
if ms < 0 { if ms < 0 {
return format!("-{}", format_millis(-ms)); return format!("-{}", format_millis(-ms));
@@ -47,8 +35,6 @@ pub fn format_millis(ms: i64) -> String {
.join(" ") .join(" ")
} }
/// `text` as a span when it is a whole number of milliseconds, and
/// unchanged when it is not.
pub fn format_millis_text(text: &str) -> String { pub fn format_millis_text(text: &str) -> String {
match text.trim().parse::<i64>() { match text.trim().parse::<i64>() {
Ok(ms) => format_millis(ms), Ok(ms) => format_millis(ms),
@@ -60,40 +46,28 @@ pub fn format_millis_text(text: &str) -> String {
mod tests { mod tests {
use super::*; use super::*;
/// The two ways a span of time is written here, and the rule each of
/// them follows -- ported from `DurationsTest.kt`, whose doc says why:
/// both are read off a screen to make a decision, so what matters is
/// that the shortest form that answers the question is what appears.
#[test] #[test]
fn under_a_minute_is_the_largest_unit_alone() { fn under_a_minute_is_the_largest_unit_alone() {
assert_eq!(format_millis(30), "30ms"); assert_eq!(format_millis(30), "30ms");
assert_eq!(format_millis(999), "999ms"); assert_eq!(format_millis(999), "999ms");
assert_eq!(format_millis(1000), "1s"); assert_eq!(format_millis(1000), "1s");
assert_eq!(format_millis(2500), "2.5s"); assert_eq!(format_millis(2500), "2.5s");
// One decimal, rounded rather than cut: 2.46s is nearer two and a
// half than two and four.
assert_eq!(format_millis(2460), "2.5s"); assert_eq!(format_millis(2460), "2.5s");
assert_eq!(format_millis(59_900), "59.9s"); assert_eq!(format_millis(59_900), "59.9s");
} }
#[test] #[test]
fn a_minute_or_more_is_every_unit_that_has_something_in_it() { fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
// The figure this rule was written for: a tool timeout, which
// arrives as milliseconds and is unreadable as 480000.
assert_eq!(format_millis(480_000), "8m"); assert_eq!(format_millis(480_000), "8m");
assert_eq!(format_millis(60_000), "1m"); assert_eq!(format_millis(60_000), "1m");
assert_eq!(format_millis(90_000), "1m 30s"); assert_eq!(format_millis(90_000), "1m 30s");
assert_eq!(format_millis(475_440_000), "5d 12h 4m"); assert_eq!(format_millis(475_440_000), "5d 12h 4m");
// Empty units are left out rather than written as zero: the labels
// say which is which, and "5d 0h 4m" is only longer.
assert_eq!(format_millis(432_240_000), "5d 4m"); assert_eq!(format_millis(432_240_000), "5d 4m");
} }
#[test] #[test]
fn only_a_whole_number_of_milliseconds_is_rewritten() { fn only_a_whole_number_of_milliseconds_is_rewritten() {
assert_eq!(format_millis_text(" 480000 "), "8m"); assert_eq!(format_millis_text(" 480000 "), "8m");
// A timeout a tool expressed some other way is its own words,
// passed through rather than guessed at.
assert_eq!(format_millis_text("2 minutes"), "2 minutes"); assert_eq!(format_millis_text("2 minutes"), "2 minutes");
assert_eq!(format_millis_text(""), ""); assert_eq!(format_millis_text(""), "");
} }
@@ -1,14 +1,9 @@
//! The SSE half of the API: one long-lived GET per open session screen,
//! replaying the transcript after a cursor and then following it live.
//! Ported from `app/.../EventStream.kt`; the framing itself is
//! [`crate::sse`].
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use event_model::SeqEvent; use event_model::SeqEvent;
use crate::api::{ApiError, Transport}; use crate::client::api::{ApiError, Transport};
use crate::sse::SseReader; use crate::client::sse::SseReader;
/// The frame name the server uses to say a cursor was too far behind to /// The frame name the server uses to say a cursor was too far behind to
/// continue from. Must match `send_backlog` in `server/src/routes.rs`. /// continue from. Must match `send_backlog` in `server/src/routes.rs`.
@@ -19,19 +14,8 @@ const RESET_EVENT: &str = "reset";
/// callbacks were for, as a single enum instead, since Rust has no /// callbacks were for, as a single enum instead, since Rust has no
/// equivalent of handing three closures to one blocking call. /// equivalent of handing three closures to one blocking call.
pub enum StreamItem { pub enum StreamItem {
/// The connection was accepted; the measured moment the stream is live
/// (see `EventStream.kt`'s doc on `onOpen` for why this, not the first
/// event, is what clears a previous failure on screen).
Open, Open,
/// The cursor was too far behind to continue from: everything already
/// displayed is stale, and the events that follow are a fresh window.
/// Arrives before those events, so a caller that clears on it stays in
/// order.
Reset, Reset,
/// One event, as both the raw line the transcript cache stores and the
/// parsed [`SeqEvent`] the fold works from -- they have to be the same
/// line, so both travel together rather than being parsed twice from
/// two call sites.
Event { raw: String, event: SeqEvent }, Event { raw: String, event: SeqEvent },
} }
@@ -59,7 +43,6 @@ pub fn follow_session_events(
let Some(frame) = reader.feed_line(&line) else { let Some(frame) = reader.feed_line(&line) else {
continue; continue;
}; };
// A named frame carries no payload and a data frame has no name.
if frame.name.as_deref() == Some(RESET_EVENT) { if frame.name.as_deref() == Some(RESET_EVENT) {
if !on_item(StreamItem::Reset) { if !on_item(StreamItem::Reset) {
return Ok(()); return Ok(());
@@ -83,7 +66,7 @@ pub fn follow_session_events(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::api::{Body, RawResponse}; use crate::client::api::{Body, RawResponse};
use std::io::Cursor; use std::io::Cursor;
struct FixtureTransport { struct FixtureTransport {
@@ -1,8 +1,3 @@
//! A language the highlighter can colour, and the data-driven [`Rules`] each
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
//! why nearly every language is a row of data read by one shared scanner,
//! with Markdown the one exception (`super::markdown`).
use std::collections::HashSet; use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -32,8 +27,6 @@ pub enum Language {
} }
impl Language { impl Language {
/// Every value, for the same exhaustiveness check the Kotlin test runs
/// (`Language.entries`).
pub const ALL: [Language; 22] = [ pub const ALL: [Language; 22] = [
Language::C, Language::C,
Language::Coffeescript, Language::Coffeescript,
@@ -60,24 +53,14 @@ impl Language {
]; ];
} }
/// What [`super::scan`] needs to know about one language -- data, not code,
/// so that adding a language is a row here rather than a branch anywhere.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct Rules { pub struct Rules {
/// Words drawn as keywords. Only plain words; the scanner cannot reach
/// anything else.
pub keywords: HashSet<&'static str>, pub keywords: HashSet<&'static str>,
/// Tokens that open a comment running to the end of the line.
pub line_comments: Vec<&'static str>, pub line_comments: Vec<&'static str>,
/// Whether `line_comments` count only at the start of a word. The shells
/// need it: `$#`, `${#x}` and `a#b` are not comments.
pub line_comments_at_word_start: bool, pub line_comments_at_word_start: bool,
pub block_comment: Option<BlockComment>, pub block_comment: Option<BlockComment>,
/// The string forms. The longest opener that matches wins, so `"""` is
/// tried before `"`.
pub quotes: Vec<Quote>, pub quotes: Vec<Quote>,
pub attributes: Attributes, pub attributes: Attributes,
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
pub raw_strings: bool, pub raw_strings: bool,
/// Rust: `'` opens a character literal only when a backslash or one /// Rust: `'` opens a character literal only when a backslash or one
/// character and a `'` follow. Otherwise it is a lifetime or a label. /// character and a `'` follow. Otherwise it is a lifetime or a label.
@@ -91,8 +74,6 @@ pub struct BlockComment {
pub nests: bool, pub nests: bool,
} }
/// One string form. `escapes` is whether a backslash escapes the closer
/// (and itself).
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct Quote { pub struct Quote {
pub open: &'static str, pub open: &'static str,
@@ -100,18 +81,13 @@ pub struct Quote {
pub escapes: bool, pub escapes: bool,
} }
/// What opens a metadata span, of the shapes that exist across these languages.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Attributes { pub enum Attributes {
#[default] #[default]
None, None,
/// `@` and a word: Kotlin and Java annotations, Python decorators.
AtWord, AtWord,
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
HashBracket, HashBracket,
/// `#` at the start of a line, to the end of it: the C preprocessor.
HashLine, HashLine,
/// `[` at the start of a line through the matching `]`: a TOML table header.
LineBracket, LineBracket,
} }
@@ -151,10 +127,6 @@ fn words(list: &'static str) -> HashSet<&'static str> {
list.split_whitespace().collect() list.split_whitespace().collect()
} }
/// The rules for one language. A `match` rather than a lazily-built map --
/// there is no once-per-process cost worth paying for in a language table
/// this small, and it sidesteps the Kotlin version's own workaround for
/// property initialization order.
pub fn rules_for(language: Language) -> Rules { pub fn rules_for(language: Language) -> Rules {
match language { match language {
Language::C => Rules { Language::C => Rules {
@@ -180,8 +152,6 @@ pub fn rules_for(language: Language) -> Rules {
quotes: vec![DOUBLE, SINGLE], quotes: vec![DOUBLE, SINGLE],
..Default::default() ..Default::default()
}, },
// `###` opens and closes a block comment and `#` opens a line one,
// which is why the scanner tries the block opener first.
Language::Coffeescript => Rules { Language::Coffeescript => Rules {
keywords: words(KEYWORDS_COFFEESCRIPT), keywords: words(KEYWORDS_COFFEESCRIPT),
line_comments: vec!["#"], line_comments: vec!["#"],
@@ -318,7 +288,6 @@ pub fn rules_for(language: Language) -> Rules {
keywords: words(KEYWORDS_SHELL), keywords: words(KEYWORDS_SHELL),
line_comments: vec!["#"], line_comments: vec!["#"],
line_comments_at_word_start: true, line_comments_at_word_start: true,
// A shell's single quotes are literal: `'a\'` is not one string.
quotes: vec![ quotes: vec![
DOUBLE, DOUBLE,
Quote { Quote {
@@ -373,16 +342,10 @@ pub fn rules_for(language: Language) -> Rules {
attributes: Attributes::AtWord, attributes: Attributes::AtWord,
..Default::default() ..Default::default()
}, },
// Markdown has no token rules; see `super::markdown::scan_markdown`.
Language::Markdown => Rules::default(), Language::Markdown => Rules::default(),
} }
} }
// The keyword sets. Every list below other than RON, TOML, fish and JSON
// came from dev.snipme:highlights 1.1.0 (Apache-2.0), the library the
// Kotlin scanner replaced, so that no fence which was coloured there turns
// plain here either.
const KEYWORDS_C: &str = const KEYWORDS_C: &str =
"auto break case char const continue default do double else enum extern float for goto if "auto break case char const continue default do double else enum extern float for goto if
int long register return short signed sizeof static struct switch typedef union unsigned int long register return short signed sizeof static struct switch typedef union unsigned
@@ -416,9 +379,6 @@ const KEYWORDS_DART: &str =
required rethrow return sealed set show static super switch this throw true try var void required rethrow return sealed set show static super switch this throw true try var void
when with while yield"; when with while yield";
/// fish is not in the library at all, so its fences are drawn plain today.
/// The list is the shell's own words, which is what a fish fence is mostly
/// made of.
const KEYWORDS_FISH: &str = const KEYWORDS_FISH: &str =
"and begin break builtin case command continue else end exec for function if in not or "and begin break builtin case command continue else end exec for function if in not or
return switch while set echo test string math read source"; return switch while set echo test string math read source";
@@ -466,7 +426,6 @@ const KEYWORDS_PYTHON: &str =
for from global if import in is lambda nonlocal not or pass raise return try while with for from global if import in is lambda nonlocal not or pass raise return try while with
yield"; yield";
/// RON is not in the library either; these are the words a RON file can hold.
const KEYWORDS_RON: &str = "true false Some None inf NaN"; const KEYWORDS_RON: &str = "true false Some None inf NaN";
const KEYWORDS_RUBY: &str = const KEYWORDS_RUBY: &str =
@@ -495,8 +454,6 @@ const KEYWORDS_SWIFT: &str =
nonmutating optional override postfix precedence prefix Protocol required right set some Type nonmutating optional override postfix precedence prefix Protocol required right set some Type
unowned weak willSet"; unowned weak willSet";
/// TOML is not in the library; `inf` and `nan` are values rather than
/// names, like the booleans.
const KEYWORDS_TOML: &str = "true false inf nan"; const KEYWORDS_TOML: &str = "true false inf nan";
const KEYWORDS_TYPESCRIPT: &str = const KEYWORDS_TYPESCRIPT: &str =
@@ -518,8 +475,6 @@ pub fn fence_language(name: Option<&str>) -> Option<Language> {
.map(|(_, language)| *language) .map(|(_, language)| *language)
} }
/// The highlighter's language for a *file*, from its name.
///
/// The extension is the part after the *last* dot, which is what makes /// The extension is the part after the *last* dot, which is what makes
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no /// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
/// extension, it has a name that starts with a dot. A name with no dot at /// extension, it has a name that starts with a dot. A name with no dot at
@@ -1,26 +1,8 @@
//! Markdown read into the spans that carry a colour -- a ```markdown fence
//! in a reply, and a `.md` file in the viewer. Ported from
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
//! scanner rather than a row of [`super::Rules`] (what a character means
//! depends on where it sits, not on what it is) and why an indented code
//! block is deliberately not recognised.
//!
//! Structure is read a line at a time and each line's prose left to right,
//! except the two decisions that are not: a fenced block is state carried
//! forward, and a table is found by its delimiter row, which comes after
//! the header it belongs to (the one place here that looks ahead).
use super::{Kind, Span}; use super::{Kind, Span};
/// The characters an unordered list may be bulleted with.
const BULLETS: &str = "-*+"; const BULLETS: &str = "-*+";
/// The characters a thematic break, or a setext heading's underline, can be
/// drawn with.
const RULE_MARKERS: &str = "-*_="; const RULE_MARKERS: &str = "-*_=";
/// The characters that can open emphasis, strong emphasis or a strikethrough.
const EMPHASIS: &str = "*_~"; const EMPHASIS: &str = "*_~";
/// Characters that end a bare URL wherever they appear, and ones only
/// trimmed off the end.
const URL_STOPS: &str = "<>\"'`|"; const URL_STOPS: &str = "<>\"'`|";
const URL_TRAILING: &str = ".,:;!?"; const URL_TRAILING: &str = ".,:;!?";
@@ -46,15 +28,10 @@ impl MarkdownScanner {
// The delimiter run that opened the fenced block we are inside, or // The delimiter run that opened the fenced block we are inside, or
// None between them. // None between them.
let mut fence: Option<Vec<char>> = None; let mut fence: Option<Vec<char>> = None;
// Whether the row above was part of a table, which is what makes
// this one a body row.
let mut table = false; let mut table = false;
loop { loop {
let end = self.line_end(at); let end = self.line_end(at);
if let Some(open) = fence.clone() { if let Some(open) = fence.clone() {
// The content and the closing line alike: a fence is one
// block of code, and its own delimiters belong to it the
// way a string's quotes belong to the string.
self.emit(at, end, Kind::String); self.emit(at, end, Kind::String);
if self.closes_fence(at, end, &open) { if self.closes_fence(at, end, &open) {
fence = None; fence = None;
@@ -76,7 +53,6 @@ impl MarkdownScanner {
self.spans self.spans
} }
/// The end of the line beginning at `at`: the newline, or the end of the text.
fn line_end(&self, at: usize) -> usize { fn line_end(&self, at: usize) -> usize {
self.code[at..] self.code[at..]
.iter() .iter()
@@ -85,8 +61,6 @@ impl MarkdownScanner {
.unwrap_or(self.code.len()) .unwrap_or(self.code.len())
} }
/// One line that is not inside a fence, and whether the table it may be
/// part of is still open.
fn row(&mut self, start: usize, end: usize, table: bool) -> bool { fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
if self.table_delimiter(start, end) { if self.table_delimiter(start, end) {
let indented = self.indented(start, end); let indented = self.indented(start, end);
@@ -102,13 +76,11 @@ impl MarkdownScanner {
false false
} }
/// A line of nothing but pipes, dashes, alignment colons and space, with
/// one of each needed.
fn table_delimiter(&self, start: usize, end: usize) -> bool { fn table_delimiter(&self, start: usize, end: usize) -> bool {
let mut dashes = false; let mut dashes = false;
let mut pipes = false; let mut pipes = false;
for at in self.indented(start, end)..end { for c in &self.code[self.indented(start, end)..end] {
match self.code[at] { match c {
'-' => dashes = true, '-' => dashes = true,
'|' => pipes = true, '|' => pipes = true,
':' | ' ' | '\t' => {} ':' | ' ' | '\t' => {}
@@ -132,7 +104,6 @@ impl MarkdownScanner {
false false
} }
/// A table row: the pipes are the structure, and what is between them is prose.
fn table_row(&mut self, start: usize, end: usize) { fn table_row(&mut self, start: usize, end: usize) {
let mut at = self.indented(start, end); let mut at = self.indented(start, end);
let mut cell = at; let mut cell = at;
@@ -151,7 +122,6 @@ impl MarkdownScanner {
self.inline(cell, end); self.inline(cell, end);
} }
/// Spans, coalesced with the one before when they touch and agree.
fn emit(&mut self, start: usize, end: usize, kind: Kind) { fn emit(&mut self, start: usize, end: usize, kind: Kind) {
if end <= start { if end <= start {
return; return;
@@ -166,7 +136,6 @@ impl MarkdownScanner {
self.spans.push(Span { start, end, kind }); self.spans.push(Span { start, end, kind });
} }
/// The first character of the line at or after `start` that is not indentation.
fn indented(&self, start: usize, end: usize) -> usize { fn indented(&self, start: usize, end: usize) -> usize {
let mut at = start; let mut at = start;
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') { while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
@@ -175,8 +144,6 @@ impl MarkdownScanner {
at at
} }
/// The run of backticks or tildes that could open or close a fence on
/// this line, or `None`.
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> { fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
let at = self.indented(start, end); let at = self.indented(start, end);
if at == end { if at == end {
@@ -193,20 +160,14 @@ impl MarkdownScanner {
if run - at >= 3 { Some((at, run)) } else { None } if run - at >= 3 { Some((at, run)) } else { None }
} }
/// Draws an opening fence line and answers its delimiter, or `None` if
/// this is not one.
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> { fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
let (run_start, run_end) = self.fence_run(start, end)?; let (run_start, run_end) = self.fence_run(start, end)?;
self.emit(run_start, run_end, Kind::String); self.emit(run_start, run_end, Kind::String);
// The info word is what the fence is a fence *of*, which is
// metadata about the block rather than part of it.
let indented = self.indented(run_end, end); let indented = self.indented(run_end, end);
self.emit(indented, end, Kind::Metadata); self.emit(indented, end, Kind::Metadata);
Some(self.code[run_start..run_end].to_vec()) Some(self.code[run_start..run_end].to_vec())
} }
/// Whether this line closes a fence opened by `open`: the same
/// character, at least as many of them, and nothing else on the line.
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool { fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
let Some((run_start, run_end)) = self.fence_run(start, end) else { let Some((run_start, run_end)) = self.fence_run(start, end) else {
return false; return false;
@@ -217,12 +178,8 @@ impl MarkdownScanner {
self.indented(run_end, end) == end self.indented(run_end, end) == end
} }
/// One ordinary line: what its opening characters make it, and then its prose.
fn structure(&mut self, start: usize, end: usize) { fn structure(&mut self, start: usize, end: usize) {
let mut at = start; let mut at = start;
// Quote markers come before everything else and can be several
// deep, and what follows one is an ordinary line again -- a heading
// inside a quote is still a heading.
while at < end && self.code[at] == '>' { while at < end && self.code[at] == '>' {
at += 1; at += 1;
self.emit(at - 1, at, Kind::Mark); self.emit(at - 1, at, Kind::Mark);
@@ -238,8 +195,6 @@ impl MarkdownScanner {
self.inline(text_start, end); self.inline(text_start, end);
} }
/// `#` to `######` and a space. Without the space it is a word
/// beginning with a hash.
fn heading(&mut self, start: usize, end: usize) -> bool { fn heading(&mut self, start: usize, end: usize) -> bool {
let mut at = start; let mut at = start;
while at < end && self.code[at] == '#' { while at < end && self.code[at] == '#' {
@@ -256,15 +211,13 @@ impl MarkdownScanner {
true true
} }
/// A line made of one repeated rule character and nothing else.
fn thematic_break(&mut self, start: usize, end: usize) -> bool { fn thematic_break(&mut self, start: usize, end: usize) -> bool {
let marker = self.code[start]; let marker = self.code[start];
if !RULE_MARKERS.contains(marker) { if !RULE_MARKERS.contains(marker) {
return false; return false;
} }
let mut seen = 0usize; let mut seen = 0usize;
for at in start..end { for &c in &self.code[start..end] {
let c = self.code[at];
if c == marker { if c == marker {
seen += 1; seen += 1;
} else if !c.is_whitespace() { } else if !c.is_whitespace() {
@@ -278,8 +231,6 @@ impl MarkdownScanner {
true true
} }
/// Draws a list marker if the line opens with one, and answers where
/// the item's text starts.
fn bullet(&mut self, start: usize, end: usize) -> usize { fn bullet(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start]; let marker = self.code[start];
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) { if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
@@ -305,16 +256,11 @@ impl MarkdownScanner {
at >= end || self.code[at] == ' ' || self.code[at] == '\t' at >= end || self.code[at] == ' ' || self.code[at] == '\t'
} }
/// The inline forms, left to right. Every branch answers a position
/// strictly after `start` of its call, so this terminates.
fn inline(&mut self, start: usize, end: usize) { fn inline(&mut self, start: usize, end: usize) {
let mut at = start; let mut at = start;
while at < end { while at < end {
let c = self.code[at]; let c = self.code[at];
at = if c == '\\' { at = if c == '\\' {
// A backslash takes the character after it out of the
// running entirely, which is how `\*` stays an asterisk
// rather than opening emphasis.
at + 2 at + 2
} else if c == '`' { } else if c == '`' {
self.code_span(at, end) self.code_span(at, end)
@@ -332,7 +278,6 @@ impl MarkdownScanner {
} }
} }
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
fn code_span(&mut self, start: usize, end: usize) -> usize { fn code_span(&mut self, start: usize, end: usize) -> usize {
let mut open = start; let mut open = start;
while open < end && self.code[open] == '`' { while open < end && self.code[open] == '`' {
@@ -355,11 +300,9 @@ impl MarkdownScanner {
} }
at = close; at = close;
} }
// Nothing closes it on this line, so those were ordinary backticks.
open open
} }
/// `[text](destination)`, and the same with a leading `!` for an image.
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize { fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
let mut depth = 0i32; let mut depth = 0i32;
let mut close = bracket; let mut close = bracket;
@@ -398,8 +341,6 @@ impl MarkdownScanner {
paren + 1 paren + 1
} }
/// `<https://example.com>` and `<name@example.com>`, drawn as the
/// destination they are.
fn autolink(&mut self, start: usize, end: usize) -> usize { fn autolink(&mut self, start: usize, end: usize) -> usize {
let mut at = start + 1; let mut at = start + 1;
let mut addressed = false; let mut addressed = false;
@@ -423,8 +364,6 @@ impl MarkdownScanner {
start + 1 start + 1
} }
/// A bare `scheme://...` written in prose, or `None` if one does not
/// start here.
fn url(&mut self, start: usize, end: usize) -> Option<usize> { fn url(&mut self, start: usize, end: usize) -> Option<usize> {
if start > 0 && is_word(self.code[start - 1]) { if start > 0 && is_word(self.code[start - 1]) {
return None; return None;
@@ -466,8 +405,6 @@ impl MarkdownScanner {
Some(at) Some(at)
} }
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
/// all.
fn emphasis(&mut self, start: usize, end: usize) -> usize { fn emphasis(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start]; let marker = self.code[start];
let mut open = start; let mut open = start;
@@ -1,24 +1,3 @@
//! `code` read once, left to right, into the spans that carry a colour.
//! Ported from `app/.../Highlighter.kt`.
//!
//! One pass with a small state -- in a comment, in a string, or in ordinary
//! code -- rather than a locator per token kind over the whole text, which
//! is what the library this replaced did and is why it found comments
//! before it knew the language: a `#` inside a shell string, a `//` inside
//! a URL and a block-comment opener inside a shell glob each commented out
//! the rest of a line that was nothing of the sort.
//!
//! Every span is produced by advancing an index forward, so the result is
//! ordered, non-overlapping and inside the code by construction. Nothing
//! here panics: an unterminated string or comment runs to the end of the
//! code, which is also what it looks like while a fence is still being
//! written.
//!
//! **Indices are char offsets, not byte offsets** -- the scanner works over
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
//! back into the text it covers.
pub mod languages; pub mod languages;
pub mod markdown; pub mod markdown;
@@ -26,7 +5,6 @@ pub use languages::{
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for, Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
}; };
/// What a span of code is, in the terms a palette has a colour for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind { pub enum Kind {
Keyword, Keyword,
@@ -38,7 +16,6 @@ pub enum Kind {
Mark, Mark,
} }
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span { pub struct Span {
pub start: usize, pub start: usize,
@@ -70,8 +47,6 @@ pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
Scanner::new(code, rules).run() Scanner::new(code, rules).run()
} }
/// Characters coloured as punctuation, and as marks. Both sets are the ones
/// the library this replaced used.
const PUNCTUATION: &str = ",.:;"; const PUNCTUATION: &str = ",.:;";
const MARKS: &str = "()={}<>-+[]|&"; const MARKS: &str = "()={}<>-+[]|&";
@@ -94,8 +69,6 @@ impl<'a> Scanner<'a> {
fn run(mut self) -> Vec<Span> { fn run(mut self) -> Vec<Span> {
while self.at < self.code.len() { while self.at < self.code.len() {
// Every branch that answers true has advanced `self.at`, so
// this terminates.
let consumed = self.block_comment() let consumed = self.block_comment()
|| self.line_comment() || self.line_comment()
|| self.raw_string() || self.raw_string()
@@ -126,15 +99,12 @@ impl<'a> Scanner<'a> {
starts_with_at(&self.code, self.at, token) starts_with_at(&self.code, self.at, token)
} }
/// Whether a line comment token here opens one; see
/// [`Rules::line_comments_at_word_start`].
fn at_word_start(&self) -> bool { fn at_word_start(&self) -> bool {
self.at == 0 self.at == 0
|| self.code[self.at - 1].is_whitespace() || self.code[self.at - 1].is_whitespace()
|| ";|&(".contains(self.code[self.at - 1]) || ";|&(".contains(self.code[self.at - 1])
} }
/// Whether only whitespace stands between the start of this line and here.
fn at_line_start(&self) -> bool { fn at_line_start(&self) -> bool {
let mut back = self.at as isize - 1; let mut back = self.at as isize - 1;
while back >= 0 && self.code[back as usize] != '\n' { while back >= 0 && self.code[back as usize] != '\n' {
@@ -152,8 +122,6 @@ impl<'a> Scanner<'a> {
} }
} }
/// From an open bracket through the one that matches it, or to the end
/// if none does.
fn advance_to_matching_bracket(&mut self) { fn advance_to_matching_bracket(&mut self) {
let mut depth = 0i32; let mut depth = 0i32;
while self.at < self.code.len() { while self.at < self.code.len() {
@@ -180,9 +148,6 @@ impl<'a> Scanner<'a> {
self.at += comment.open.chars().count(); self.at += comment.open.chars().count();
let mut depth = 1i32; let mut depth = 1i32;
while self.at < self.code.len() && depth > 0 { while self.at < self.code.len() && depth > 0 {
// The closer is tried first so that a language whose two
// delimiters are the same string -- CoffeeScript's `###` --
// closes rather than nesting forever.
if self.starts(comment.close) { if self.starts(comment.close) {
depth -= 1; depth -= 1;
self.at += comment.close.chars().count(); self.at += comment.close.chars().count();
@@ -210,7 +175,6 @@ impl<'a> Scanner<'a> {
true true
} }
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
fn raw_string(&mut self) -> bool { fn raw_string(&mut self) -> bool {
if !self.rules.raw_strings { if !self.rules.raw_strings {
return false; return false;
@@ -267,8 +231,6 @@ impl<'a> Scanner<'a> {
} }
fn string(&mut self) -> bool { fn string(&mut self) -> bool {
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
// than an empty string followed by a quote.
let mut quote: Option<Quote> = None; let mut quote: Option<Quote> = None;
for candidate in &self.rules.quotes { for candidate in &self.rules.quotes {
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0); let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
@@ -346,9 +308,6 @@ impl<'a> Scanner<'a> {
true true
} }
/// A number is a run starting with a digit and carrying on through
/// letters, digits, `_` and `.` -- which covers `0xFF`, `1_000`, `1u32`
/// and `3.14` without a grammar for any of them.
fn number(&mut self) -> bool { fn number(&mut self) -> bool {
if !self.code[self.at].is_ascii_digit() { if !self.code[self.at].is_ascii_digit() {
return false; return false;
@@ -403,7 +362,6 @@ fn is_word_part(c: char) -> bool {
c.is_alphanumeric() || c == '_' c.is_alphanumeric() || c == '_'
} }
/// Whether `code[at..]` starts with `token`, both read as chars.
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool { fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
let token: Vec<char> = token.chars().collect(); let token: Vec<char> = token.chars().collect();
if at + token.len() > code.len() { if at + token.len() > code.len() {
@@ -412,8 +370,6 @@ fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
code[at..at + token.len()] == token[..] code[at..at + token.len()] == token[..]
} }
/// The first index at or after `from` where `code` contains `needle`, or
/// `None`.
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> { fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
if needle.is_empty() || from > code.len() { if needle.is_empty() || from > code.len() {
return None; return None;
@@ -640,10 +596,6 @@ mod tests {
} }
} }
/// The scanner must never panic and must never answer a span the code
/// does not contain: the library this replaced answered a reversed
/// range here, which crashed a card, and a fence still being written is
/// an unterminated string or comment on every keystroke.
#[test] #[test]
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() { fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
let nasty = [ let nasty = [
+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"));
}
}
@@ -1,33 +1,3 @@
//! Split a markdown message into its top-level **blocks** -- one
//! paragraph, heading, fenced code block, list, table or quote each, as a
//! byte slice of the original source.
//!
//! This exists for streaming. A transcript row used to be one text widget
//! holding the whole message, so a single streamed delta re-shaped every
//! paragraph of it through the text engine again; the phone's bench v2 put
//! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly
//! that reason (docs/IRIS_TODO.md). A row is a column of one widget per
//! block now, and a delta that lands in the last block leaves every
//! earlier block's layout alone. `docs/DECISIONS.md`'s 2026-09-06 entry has
//! what that rejected and why the split lives here rather than in the UI
//! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and
//! keeping it here means iris stays a text renderer that knows nothing
//! about markdown.
//!
//! **Blocks only.** Inline styling (bold, links, inline code) is still the
//! renderer's own job, per block -- this deliberately does not build a
//! full AST, because nothing needs one yet.
//!
//! ## Appending is not guaranteed to leave earlier blocks alone
//!
//! It nearly always does, which is what makes the fast path worth having,
//! but markdown has no such rule: appending a "```" line can turn text
//! that was three paragraphs into one fenced block, and appending "---"
//! under a paragraph turns that paragraph into a heading. So a caller
//! taking the O(last block) path **must compare the prefix it is about to
//! keep** rather than assume it. [`common_prefix`] is that comparison, and
//! it is cheap next to laying the text out again.
use pulldown_cmark::{Event, Options, Parser, Tag}; use pulldown_cmark::{Event, Options, Parser, Tag};
/// What a block is, for a renderer that wants to style or space blocks /// What a block is, for a renderer that wants to style or space blocks
@@ -40,20 +10,13 @@ use pulldown_cmark::{Event, Options, Parser, Tag};
pub enum BlockKind { pub enum BlockKind {
Paragraph, Paragraph,
Heading, Heading,
/// A fenced or indented code block.
Code, Code,
List, List,
Table, Table,
Quote, Quote,
/// A thematic break, raw HTML, a footnote -- anything with no
/// distinguished treatment here.
Other, Other,
} }
/// One top-level block: its kind and the exact source that produced it.
/// `source` is a slice of the input with trailing whitespace removed, so
/// two splits of the same prefix compare equal even when one of them had a
/// delta arriving after it.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Block { pub struct Block {
pub kind: BlockKind, pub kind: BlockKind,
@@ -73,16 +36,9 @@ fn kind_of(tag: &Tag) -> BlockKind {
} }
fn options() -> Options { fn options() -> Options {
// The same set `transcript-ui`'s renderer parses with, so a block
// boundary here and the styling there cannot disagree about what the
// source means.
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
} }
/// Split `src` into its top-level blocks, in source order. An empty or
/// whitespace-only input gives no blocks; text the parser does not put
/// inside any block (a stray fence marker mid-stream) still comes back,
/// as `Other`, rather than being dropped.
pub fn split_blocks(src: &str) -> Vec<Block> { pub fn split_blocks(src: &str) -> Vec<Block> {
let mut out: Vec<Block> = Vec::new(); let mut out: Vec<Block> = Vec::new();
let mut depth = 0usize; let mut depth = 0usize;
@@ -101,9 +57,6 @@ pub fn split_blocks(src: &str) -> Vec<Block> {
push(&mut out, kind, &src[range]); push(&mut out, kind, &src[range]);
} }
} }
// A top-level event that is not part of any block -- a
// thematic break, a block of raw HTML. Inside one, it is the
// enclosing block's business and this does nothing.
_ => { _ => {
if depth == 0 { if depth == 0 {
push(&mut out, BlockKind::Other, &src[range]); push(&mut out, BlockKind::Other, &src[range]);
@@ -163,9 +116,6 @@ mod tests {
assert!(split_blocks(" \n\n ").is_empty()); assert!(split_blocks(" \n\n ").is_empty());
} }
/// The property the streaming fast path rests on, in its ordinary
/// shape: a delta landing in the last paragraph must leave every
/// earlier block byte-identical.
#[test] #[test]
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() { fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par"); let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
@@ -176,9 +126,6 @@ mod tests {
assert_ne!(before[2], after[2]); assert_ne!(before[2], after[2]);
} }
/// A delta that starts a *new* block keeps every old block, including
/// the one that was last -- so the fast path appends rather than
/// replacing.
#[test] #[test]
fn a_delta_that_starts_a_new_block_keeps_every_old_one() { fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
let before = split_blocks("First para.\n\nSecond para."); let before = split_blocks("First para.\n\nSecond para.");
@@ -187,10 +134,6 @@ mod tests {
assert_eq!(after.len(), 3); assert_eq!(after.len(), 3);
} }
/// A code fence arrives one delta at a time and is unterminated for
/// most of its life. It must still be *one* block the whole way, or
/// every delta would re-split the message into a different number of
/// pieces.
#[test] #[test]
fn an_unterminated_fence_is_one_block_while_it_streams() { fn an_unterminated_fence_is_one_block_while_it_streams() {
for src in [ for src in [
@@ -206,11 +149,6 @@ mod tests {
} }
} }
/// The half the fast path had no reason to touch, and the reason
/// `common_prefix` is a comparison rather than an assumption:
/// appending can rewrite what came before. `---` under a paragraph
/// turns that paragraph into a setext heading, so the block that was
/// already laid out is not the block it is now.
#[test] #[test]
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() { fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
let before = split_blocks("Not a heading\n\nsecond"); let before = split_blocks("Not a heading\n\nsecond");
@@ -232,12 +170,6 @@ mod tests {
); );
} }
/// The shapes a real transcript actually contains, each checked for
/// the one property the streaming fast path needs: the *number* of
/// blocks and every earlier block's source stay put while the message
/// grows. A fence's own blank lines, a `---` inside one, a nested
/// list and a table are all places where a naive line-based split
/// would break the message into more pieces than there are blocks.
#[test] #[test]
fn the_transcripts_own_block_shapes_survive_a_split() { 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."; let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
@@ -271,19 +203,9 @@ mod tests {
); );
} }
/// `apply_delta`'s precondition, stated as the property rather than
/// the arithmetic: for every prefix of a realistic streamed message,
/// the blocks before the last one must be exactly the blocks the
/// previous prefix had. Where markdown breaks that (the `---` case
/// above), `common_prefix` has to *say* so -- which is what the
/// `>= len - 1` assertion below checks: the split may rewrite the
/// last block, never an earlier one, or `RowBlocks::apply_delta`
/// would keep a widget whose text is no longer what it holds.
#[test] #[test]
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() { 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 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.";
// Every character boundary, so a delta landing mid-word and one
// landing exactly on a fence's closing backtick are both covered.
let mut prev = Vec::new(); let mut prev = Vec::new();
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) { for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
let now = split_blocks(&full[..end]); let now = split_blocks(&full[..end]);
@@ -297,9 +219,6 @@ mod tests {
} }
} }
/// The half a growing message cannot show: a fence that never closes.
/// The stream ends there and the block must still be the code block
/// it has been all along, not re-split into paragraphs.
#[test] #[test]
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() { 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 src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
@@ -311,9 +230,6 @@ mod tests {
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line"); assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
} }
/// A delta that closes a fence changes the *last* block only, so the
/// fast path takes it -- the case the module doc says is the reason
/// `common_prefix` is a comparison.
#[test] #[test]
fn the_delta_that_closes_a_fence_changes_only_the_last_block() { fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
let before = split_blocks("Text.\n\n```\ncode\n"); let before = split_blocks("Text.\n\n```\ncode\n");
@@ -1,16 +1,14 @@
//! The app's pure logic, shared between the server and any Rust client --
//! see `docs/CLIENT_CORE.md` for what lives here and what does
//! not yet.
pub mod ansi; pub mod ansi;
pub mod api; pub mod api;
pub mod config; pub mod config;
pub mod durations; pub mod durations;
pub mod event_stream; pub mod event_stream;
pub mod highlight; pub mod highlight;
pub mod log_ring;
pub mod markdown_blocks; pub mod markdown_blocks;
pub mod notifications; pub mod notifications;
pub mod sse; pub mod sse;
pub mod text_cap;
pub mod tool_summary; pub mod tool_summary;
pub mod transcript_cache; pub mod transcript_cache;
pub mod transcript_fold; pub mod transcript_fold;
@@ -1,27 +1,17 @@
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two //! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
//! places, never both" describes. Ported from the parsing half of //! places, never both" describes. Ported from the parsing half of
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing //! `app/.../Notifications.kt`'s `NotificationService` -- the framing
//! ([`crate::sse`]) and the wire shape ([`SessionNotification`], //! ([`crate::client::sse`]) and the wire shape ([`SessionNotification`],
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s //! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
//! `Notification`/`NotificationKind`). //! `Notification`/`NotificationKind`).
//!
//! What is deliberately **not** here, because it is a decision rather than
//! logic: whether a given notification is shown at all (the session on
//! screen gets nothing), handed to the app as a banner, or posted to the
//! platform's own notification drawer. That three-way choice reads
//! process-wide state (what screen is open, whether the app is in front)
//! that has no meaning to a pure crate with no UI and no Android in it --
//! see `android-shell` for where it lives for this port.
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use serde::Deserialize; use serde::Deserialize;
use crate::api::{ApiError, Transport}; use crate::client::api::{ApiError, Transport};
use crate::sse::SseReader; use crate::client::sse::SseReader;
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
/// `Notification` field for field.
#[derive(Debug, Clone, PartialEq, Deserialize)] #[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SessionNotification { pub struct SessionNotification {
@@ -32,9 +22,6 @@ pub struct SessionNotification {
pub at: f64, pub at: f64,
} }
/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized
/// the same way, so this deserializes the wire's `"awaitingInput"` /
/// `"finished"` directly rather than through a string match.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum NotificationKind { pub enum NotificationKind {
@@ -93,7 +80,7 @@ pub fn follow_notifications(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::api::{Body, RawResponse}; use crate::client::api::{Body, RawResponse};
use std::io::Cursor; use std::io::Cursor;
struct FixtureTransport { struct FixtureTransport {
@@ -1,15 +1,3 @@
//! Server-sent-events framing, ported from `app/.../Sse.kt`: `data:` and
//! `event:` lines accumulate until a blank line ends the frame, comments
//! start with `:`, and a frame is either named with no payload or a payload
//! with no name.
//!
//! Pure and line-at-a-time, unlike the Kotlin original which also owned the
//! socket: `server/routes.rs`'s SSE bodies are one event per line, so a
//! caller here feeds lines from wherever they came from (a real connection,
//! a test fixture) and gets frames back with no I/O of its own -- which is
//! what lets this be tested with no server, per RUST.md's "pure logic
//! first" for this crate.
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload. /// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame { pub struct Frame {
@@ -17,10 +5,6 @@ pub struct Frame {
pub data: String, pub data: String,
} }
/// Accumulates lines into [`Frame`]s. One instance per connection --
/// `feed_line` is called for every line the transport reads (with line
/// endings already stripped), and answers a frame when a blank line closes
/// one.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct SseReader { pub struct SseReader {
data: String, data: String,
@@ -32,8 +16,6 @@ impl SseReader {
Self::default() Self::default()
} }
/// Feeds one line (no trailing `\n`). Answers the frame this line
/// completed, if any.
pub fn feed_line(&mut self, line: &str) -> Option<Frame> { pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
if line.is_empty() { if line.is_empty() {
if self.name.is_some() || !self.data.is_empty() { if self.name.is_some() || !self.data.is_empty() {
@@ -50,7 +32,6 @@ impl SseReader {
} else if let Some(rest) = line.strip_prefix("event:") { } else if let Some(rest) = line.strip_prefix("event:") {
self.name = Some(rest.trim().to_string()); self.name = Some(rest.trim().to_string());
} }
// `id:`, comments -- nothing to do.
None None
} }
} }
+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"
);
}
}
@@ -1,31 +1,11 @@
//! A tool call's input, read rather than dumped -- the port of use crate::client::durations::format_millis_text;
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed use crate::client::highlight::Language;
//! card's one-line summary and the expanded card's key/value list are
//! derived from.
//!
//! Every tool's input arrives as JSON, and showing it raw makes the reader
//! parse `{"command":"…","timeout":120000}` themselves to find the one
//! line they care about. So the fields that carry the meaning are pulled
//! out, and anything left over is still shown, because dropping a field
//! would be claiming the tool has no other input when it might.
//!
//! Pure, and here rather than in the widget crate, for the reason the rest
//! of this crate exists: the derivation is the same on a phone and on a
//! desktop, and it is testable without a renderer.
use crate::durations::format_millis_text;
use crate::highlight::Language;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
/// A tool call's input, split into the parts a card draws separately.
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ToolInput { pub struct ToolInput {
/// The thing that will actually be run or read, if this tool has one.
pub subject: Option<String>, pub subject: Option<String>,
/// The language [`ToolInput::subject`] is written in, for
/// highlighting.
pub language: Option<Language>, pub language: Option<Language>,
/// The tool's own one-line summary, when it wrote one.
pub description: Option<String>, pub description: Option<String>,
/// How long the call may take, in the largest units it fits. Shown /// 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 /// apart because it is a limit on the call rather than part of what
@@ -36,25 +16,14 @@ pub struct ToolInput {
} }
impl ToolInput { impl ToolInput {
/// The one line to show when there is only room for one: what this
/// call is for.
pub fn title(&self) -> Option<&str> { pub fn title(&self) -> Option<&str> {
self.description self.description
.as_deref() .as_deref()
.or(self.subject.as_deref()) .or(self.subject.as_deref())
// A subject that is only whitespace would draw as an empty
// summary line, which reads as a tool with nothing to say
// rather than as one whose subject was blank.
.filter(|t| !t.trim().is_empty()) .filter(|t| !t.trim().is_empty())
} }
} }
/// Which field of which tool is the subject.
///
/// A table rather than a chain of `if`s: adding a tool is a row, and the
/// shape stops any of them from being the special case that gets its own
/// code path. Unknown tools fall through to "no subject, everything is
/// rest".
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[ const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
("Bash", "command", Some(Language::Shell)), ("Bash", "command", Some(Language::Shell)),
("Read", "file_path", None), ("Read", "file_path", None),
@@ -65,13 +34,8 @@ const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
("WebFetch", "url", None), ("WebFetch", "url", None),
]; ];
/// Fields that are the tool's own prose about itself rather than input to
/// it.
const DESCRIPTIONS: &[&str] = &["description", "prompt"]; const DESCRIPTIONS: &[&str] = &["description", "prompt"];
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
/// string is its own characters, anything else is its JSON form.
///
/// One function rather than two, because the same coercion decides both /// 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 /// what a subject reads as and what a leftover field's value reads as, and
/// two copies would eventually disagree about a number. /// two copies would eventually disagree about a number.
@@ -87,11 +51,6 @@ fn non_blank(value: Option<&Value>) -> Option<String> {
(!text.trim().is_empty()).then_some(text) (!text.trim().is_empty()).then_some(text)
} }
/// Split `input` (a tool call's JSON) into the parts a card draws.
///
/// Input that is not a JSON object -- older transcripts and some tools
/// send a bare string -- is still the input, so it is still shown, as the
/// whole of `rest`.
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput { pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else { let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
return ToolInput { return ToolInput {
@@ -117,10 +76,6 @@ fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
.find_map(|key| non_blank(json.get(*key))); .find_map(|key| non_blank(json.get(*key)));
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t)); let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
// Sorted, so the leftovers are in the same order every time this call
// is drawn rather than in whatever order the JSON happened to arrive
// in. A field is left out only when it is already drawn somewhere
// else on the card.
let mut keys: Vec<&String> = json let mut keys: Vec<&String> = json
.keys() .keys()
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none()) .filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
@@ -148,9 +103,6 @@ mod tests {
#[test] #[test]
fn each_tool_in_the_table_has_its_own_subject() { fn each_tool_in_the_table_has_its_own_subject() {
// One assertion per row of `SUBJECTS`, because the table is the
// whole of the rule and a row lost in an edit would otherwise
// only show up as a card with no summary line.
let cases = [ let cases = [
("Bash", r#"{"command":"ls -la"}"#, "ls -la"), ("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"), ("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
@@ -175,9 +127,6 @@ mod tests {
#[test] #[test]
fn a_tools_own_description_is_what_the_one_line_says() { fn a_tools_own_description_is_what_the_one_line_says() {
// The description wins over the subject: it is the tool's own
// prose about what this call is for, which is what a reader
// scanning a collapsed run is looking for.
let parsed = parse_tool_input( let parsed = parse_tool_input(
"Bash", "Bash",
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#, r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
@@ -196,9 +145,6 @@ mod tests {
#[test] #[test]
fn every_field_not_drawn_elsewhere_is_still_shown() { fn every_field_not_drawn_elsewhere_is_still_shown() {
// The half the "never dropped" promise is about: a tool this
// build has never heard of has no subject, so *everything* is
// rest -- and a known tool's extra fields are too.
let parsed = parse_tool_input( let parsed = parse_tool_input(
"Edit", "Edit",
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#, r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
@@ -219,8 +165,6 @@ mod tests {
#[test] #[test]
fn input_that_is_not_an_object_is_still_the_input() { fn input_that_is_not_an_object_is_still_the_input() {
// Older transcripts and some tools send a bare string; a card
// that dropped it would claim the call had no input at all.
assert_eq!( assert_eq!(
parse_tool_input("Bash", "just a string").rest, parse_tool_input("Bash", "just a string").rest,
vec!["just a string".to_string()] vec!["just a string".to_string()]
@@ -234,8 +178,6 @@ mod tests {
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#); let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
assert_eq!(parsed.subject, None); assert_eq!(parsed.subject, None);
assert_eq!(parsed.title(), None); assert_eq!(parsed.title(), None);
// Not dropped just because it was blank -- it is still a field
// the call carried.
assert_eq!( assert_eq!(
parsed.rest, parsed.rest,
vec!["command: ".to_string(), "other: 1".to_string()] vec!["command: ".to_string(), "other: 1".to_string()]
@@ -1,53 +1,17 @@
//! This phone's copy of the transcripts it has already been sent, so
//! reopening a session does not download it again. Ported from
//! `app/.../TranscriptCache.kt`; see `docs/TRANSCRIPT_CACHE.md`
//! for the design and `docs/CLIENT_CORE.md` for how this file corresponds to it.
//!
//! What is stored is the server's own JSON for one event per line, in
//! transcript order. Reading the cache means running the same [`seq_of`]
//! the network path runs, so a cached transcript and a fetched one cannot
//! draw differently, and an event type this build does not know keeps
//! every field it arrived with for the build that will. Rows are
//! deliberately *not* what is stored: a row is a rendering, and a cache of
//! rows would need throwing away on every update that touched the fold.
//!
//! Four rules run through all of it:
//! 1. what is on screen is what the server's transcript says, in order,
//! with nothing missing -- the cache is a copy and is never inferred,
//! folded or edited here;
//! 2. a cached line is never ahead of the live cursor, and the cursor never
//! ahead of the cache;
//! 3. the cache is never load-bearing -- missing, evicted, damaged or
//! unwritable all degrade to a cold open, never to a blank or a wrong
//! screen;
//! 4. a line already on the phone is not fetched again.
//!
//! No JSON parser here: what it needs off a line is the sequence number and
//! whether the line is a streamed delta, both read with a regex-free scan
//! (see [`seq_of`] and [`is_delta`]). A line it cannot read that way is
//! treated as damage.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::fs; use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
/// How much of this phone's cache directory all of one server's transcripts
/// may take. A dozen of the largest transcripts seen in the dev VM (21 MB
/// for 24,000 events) and a small fraction of a phone. A number to revisit
/// against real use rather than a measurement of anything.
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000; pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
/// What the newest cached line says, which is what the probe checks against
/// the server.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CachedTail { pub struct CachedTail {
pub seq: u64, pub seq: u64,
pub line: String, pub line: String,
} }
/// This phone's cache root for one server, holding one directory per session.
pub struct TranscriptCache { pub struct TranscriptCache {
root: PathBuf, root: PathBuf,
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>, warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
@@ -68,8 +32,6 @@ impl TranscriptCache {
} }
} }
/// The cache for one session, whether or not anything has been stored
/// for it yet.
pub fn session(&self, id: &str) -> SessionCache { pub fn session(&self, id: &str) -> SessionCache {
SessionCache::new(self.root.join(id), self.warn.clone()) SessionCache::new(self.root.join(id), self.warn.clone())
} }
@@ -164,36 +126,10 @@ fn dir_size(path: &Path) -> u64 {
.sum() .sum()
} }
/// One session's cached lines, as a directory of chunks.
///
/// A chunk is a set of lines *and a claim about what they cover*, and the
/// two are not the same thing: a coalesced page joins each run of streamed
/// deltas into one event carrying the seq of the run's oldest delta, so a
/// page whose newest event is seq 1,200 may cover everything up to the
/// 1,650 it was fetched with, and nothing in the lines says so. So coverage
/// is the half-open range in the file's name:
/// `<first>-<end>.rows.jsonl` (a coalesced page; `end` is the `before` it
/// was fetched with) or `<first>-<end>.raw.jsonl` (an uncoalesced page, or a
/// closed live run); `<first>-open.raw.jsonl` is the live run, whose end is
/// its last line's seq + 1.
///
/// Two chunks are adjacent when one's `end` is the other's `first`. Only /// Two chunks are adjacent when one's `end` is the other's `first`. Only
/// the contiguous run ending at the newest chunk -- the **suffix** -- is /// the contiguous run ending at the newest chunk -- the **suffix** -- is
/// ever served: chunks behind a gap are kept, because the gap is usually /// ever served: chunks behind a gap are kept, because the gap is usually
/// closed by paging back through it, but nothing is served across one. /// closed by paging back through it, but nothing is served across one.
///
/// **The newest chunk is always raw**, which is what makes the stream
/// cursor and the probe well defined.
///
/// Nothing here is load-bearing. Every operation that touches the disk
/// answers as though the cache were empty when it cannot, and a write
/// failure disables writing for the rest of this instance's life so that a
/// full disk costs one log line rather than one per delta.
///
/// A `Mutex` around the writer state stands in for Kotlin's `@Synchronized`:
/// the stream appends live events from its own thread while a reader
/// scrolling back reads pages from another, and this is what keeps the open
/// chunk's name, its end and its writer from being read half-rotated.
pub struct SessionCache { pub struct SessionCache {
dir: PathBuf, dir: PathBuf,
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>, warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
@@ -202,8 +138,6 @@ pub struct SessionCache {
#[derive(Default)] #[derive(Default)]
struct WriterState { struct WriterState {
/// Set by the first write that fails: a second would fail the same way,
/// once per delta.
disabled: bool, disabled: bool,
writer: Option<fs::File>, writer: Option<fs::File>,
open_file: Option<PathBuf>, open_file: Option<PathBuf>,
@@ -238,7 +172,6 @@ impl SessionCache {
}) })
} }
/// The newest `limit` lines of the suffix, oldest first -- the opening window.
pub fn newest(&self, limit: usize) -> Vec<String> { pub fn newest(&self, limit: usize) -> Vec<String> {
self.guard(Vec::new(), |this, state| { self.guard(Vec::new(), |this, state| {
let mut taken: VecDeque<String> = VecDeque::new(); let mut taken: VecDeque<String> = VecDeque::new();
@@ -262,11 +195,6 @@ impl SessionCache {
/// below `before` -- and means the server has to be asked. Deliberately /// below `before` -- and means the server has to be asked. Deliberately
/// not an empty list: an empty page is how the screen is told it has /// not an empty list: an empty page is how the screen is told it has
/// reached the start of the conversation. /// reached the start of the conversation.
///
/// With `rows` the count is rows rather than lines, mirroring the
/// server's `parse_coalesced`. The deltas are not joined here -- the
/// fold does that, and the joined row keeps the seq of its first delta
/// either way.
pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> { pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> {
self.guard(None, |this, state| { self.guard(None, |this, state| {
let suffix = this.suffix(state)?; let suffix = this.suffix(state)?;
@@ -291,18 +219,12 @@ impl SessionCache {
continue; continue;
} }
this.each_line(state, chunk, |line| { this.each_line(state, chunk, |line| {
// The page is what is *before* the cursor; the rows at
// or above it are already on screen.
let seq = seq_of(line).expect("chunk lines are checked in each_line"); let seq = seq_of(line).expect("chunk lines are checked in each_line");
if seq >= before { if seq >= before {
return true; return true;
} }
if rows { if rows {
let delta = is_delta(line); let delta = is_delta(line);
// Stop only between rows: a delta continuing the
// run being gathered is part of a row already
// counted, and breaking on it would drop the half
// of that row already taken.
if counted >= limit && !(delta && in_run) { if counted >= limit && !(delta && in_run) {
wanting = false; wanting = false;
} else { } else {
@@ -338,9 +260,6 @@ impl SessionCache {
}) })
} }
/// Stores a fetched page covering `[first, end)`; `false` when it was
/// not stored.
///
/// Refused when it overlaps a chunk already here, because there is no /// Refused when it overlaps a chunk already here, because there is no
/// clean cut: a coalesced event cannot be split at a seq inside its own /// clean cut: a coalesced event cannot be split at a seq inside its own
/// delta run. The caller keeps that from arising by bounding what it /// delta run. The caller keeps that from arising by bounding what it
@@ -377,13 +296,6 @@ impl SessionCache {
}) })
} }
/// Appends one live event, which is also how a freshly fetched opening
/// window is stored.
///
/// A seq equal to the open chunk's end extends it. A larger one is a
/// gap -- which is what a `reset` looks like from here -- and closes
/// the open chunk under the end it turned out to have. A smaller one is
/// already covered and is ignored; the SSE contract is `seq > after`.
pub fn append(&self, line: &str, seq: u64) { pub fn append(&self, line: &str, seq: u64) {
self.guard((), |this, state| { self.guard((), |this, state| {
if state.disabled { if state.disabled {
@@ -392,13 +304,6 @@ impl SessionCache {
let Some(writer) = this.writer_for(state, seq)? else { let Some(writer) = this.writer_for(state, seq)? else {
return Ok(()); return Ok(());
}; };
// Written as it arrived. A newline inside it would split one
// event into two unreadable halves. No source here can produce
// one -- an SSE `data:` field cannot hold a raw newline, and a
// fetched line is one element of a compact JSON array -- but
// that is a fact about the *server's* serializer rather than
// anything this file controls, so it is checked rather than
// trusted.
debug_assert!( debug_assert!(
!line.contains('\n'), !line.contains('\n'),
"a cached transcript line must be one line: {line}" "a cached transcript line must be one line: {line}"
@@ -411,7 +316,6 @@ impl SessionCache {
}); });
} }
/// Flushes what [`Self::append`] has buffered.
pub fn flush(&self) { pub fn flush(&self) {
self.guard((), |_this, state| { self.guard((), |_this, state| {
if let Some(writer) = state.writer.as_mut() { if let Some(writer) = state.writer.as_mut() {
@@ -422,12 +326,10 @@ impl SessionCache {
}); });
} }
/// What [`Self::purge`] would discard, for the reload row in session settings.
pub fn bytes(&self) -> u64 { pub fn bytes(&self) -> u64 {
self.guard(0, |this, _state| Ok(dir_size(&this.dir))) self.guard(0, |this, _state| Ok(dir_size(&this.dir)))
} }
/// Marks this session as visited, which is what eviction ranks by.
pub fn touch(&self) { pub fn touch(&self) {
self.guard((), |this, _state| { self.guard((), |this, _state| {
if this.dir.is_dir() { if this.dir.is_dir() {
@@ -448,8 +350,6 @@ impl SessionCache {
}); });
} }
// -- chunks ------------------------------------------------------------------------------
/// Every chunk on disk, oldest first. A name this does not recognise is /// Every chunk on disk, oldest first. A name this does not recognise is
/// not ours and is ignored. Recomputed per operation rather than kept: /// not ours and is ignored. Recomputed per operation rather than kept:
/// another operation may have changed the directory. /// another operation may have changed the directory.
@@ -475,9 +375,6 @@ impl SessionCache {
} else { } else {
end_str.parse::<u64>().ok() end_str.parse::<u64>().ok()
}; };
// A chunk covering nothing is one that was created and never
// written to -- an append whose very first write failed. It
// says nothing, so it is not a chunk.
if let Some(end) = end if let Some(end) = end
&& end > first && end > first
{ {
@@ -494,13 +391,6 @@ impl SessionCache {
Ok(out) Ok(out)
} }
/// The open chunk's end: its last line's seq plus one, or the in-memory
/// end while this instance is the one writing it.
///
/// An open chunk whose last line cannot be read is this app having died
/// mid-write. That line is dropped and the file truncated to the last
/// good one, which is the one place damage is repaired rather than
/// discarded.
fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> { fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> {
if state.open_file.as_deref() == Some(file) && state.open_end > 0 { if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
return Some(state.open_end); return Some(state.open_end);
@@ -516,12 +406,6 @@ impl SessionCache {
Some(end) Some(end)
} }
/// The contiguous run of adjacent chunks ending at the newest one,
/// oldest first.
///
/// A newest chunk that is not raw cannot happen while this code is the
/// only writer, and means the directory is not to be trusted -- so the
/// session is discarded.
fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> { fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> {
let all = self.chunks(state)?; let all = self.chunks(state)?;
let Some(newest) = all.last() else { let Some(newest) = all.last() else {
@@ -540,8 +424,6 @@ impl SessionCache {
Ok(run.into_iter().collect()) Ok(run.into_iter().collect())
} }
/// Each line of `chunk`, newest first, until `take` says stop.
///
/// Damage anywhere but at the tail of the open chunk was not written by /// Damage anywhere but at the tail of the open chunk was not written by
/// this code, and there is no honest way to say what a chunk covers /// this code, and there is no honest way to say what a chunk covers
/// with a line of it unreadable -- so it is treated as damage rather /// with a line of it unreadable -- so it is treated as damage rather
@@ -565,10 +447,6 @@ impl SessionCache {
}); });
} }
// -- writing -----------------------------------------------------------------------------
/// The writer for the chunk `seq` belongs in, opening or rotating one
/// as it has to.
fn writer_for<'s>( fn writer_for<'s>(
&self, &self,
state: &'s mut WriterState, state: &'s mut WriterState,
@@ -581,14 +459,10 @@ impl SessionCache {
if seq < state.open_end { if seq < state.open_end {
return Ok(None); return Ok(None);
} }
// A gap: what this instance has written covers up to `open_end`,
// and that is the name the chunk gets before a new one starts
// at the arriving seq.
let end = state.open_end; let end = state.open_end;
self.close_open_chunk(state, end); self.close_open_chunk(state, end);
} }
fs::create_dir_all(&self.dir)?; fs::create_dir_all(&self.dir)?;
// An open chunk left by an earlier instance, or by an earlier screen.
let existing = self.chunks(state)?.into_iter().rfind(|c| c.open); let existing = self.chunks(state)?.into_iter().rfind(|c| c.open);
if let Some(existing) = existing { if let Some(existing) = existing {
if seq < existing.end { if seq < existing.end {
@@ -632,8 +506,6 @@ impl SessionCache {
Ok(state.writer.as_mut()) Ok(state.writer.as_mut())
} }
/// Renames the open chunk to the range it turned out to cover, so it
/// stops being open.
fn close_open_chunk(&self, state: &mut WriterState, end: u64) { fn close_open_chunk(&self, state: &mut WriterState, end: u64) {
let file = state.open_file.clone(); let file = state.open_file.clone();
close_writer(state); close_writer(state);
@@ -647,31 +519,17 @@ impl SessionCache {
} }
} }
// -- failure -----------------------------------------------------------------------------
/// Runs `body`, answering `if_broken` when the directory cannot give a
/// real answer. None of this is reported on screen: every read here has
/// a network path beside it producing the same result, and the reader
/// has nothing to do about it. Damage discards this session's cache,
/// which makes the next open an ordinary cold one.
fn guard<T>( fn guard<T>(
&self, &self,
if_broken: T, if_broken: T,
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>, body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
) -> T { ) -> T {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
// A disk that refused once will refuse again, once per delta, so
// the first refusal is also the last.
if state.disabled { if state.disabled {
return if_broken; return if_broken;
} }
DAMAGED.with(|cell| *cell.borrow_mut() = None); DAMAGED.with(|cell| *cell.borrow_mut() = None);
let result = body(self, &mut state); let result = body(self, &mut state);
// Damage takes priority over whatever `body` returned, `Ok` or
// `Err`: `suffix` signals it by returning `Err(damaged(..))`
// precisely so this check catches it before the branch below
// mistakes it for a real I/O failure and disables the whole cache
// over one corrupt session.
if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) { if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) {
(self.warn)(&format!( (self.warn)(&format!(
"transcript cache damaged at {}; discarding {}", "transcript cache damaged at {}; discarding {}",
@@ -695,11 +553,6 @@ impl SessionCache {
} }
thread_local! { thread_local! {
/// How [`SessionCache::each_line`] reports a line it cannot make sense
/// of back up to [`SessionCache::guard`], since the callback it hands
/// `each_line_backwards` cannot itself return a `Result`. Thread-local
/// rather than a field: the guard that reads it always runs on the same
/// call stack that could have set it, one `guard` call at a time.
static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) }; static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
} }
@@ -727,8 +580,6 @@ fn rename_chunk(file: &Path, dir: &Path, first: u64, end: u64) {
let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl"))); let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl")));
} }
/// `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is
/// not ours.
fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> { fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
let rest = name.strip_suffix(".jsonl")?; let rest = name.strip_suffix(".jsonl")?;
let (rest, kind) = rest.rsplit_once('.')?; let (rest, kind) = rest.rsplit_once('.')?;
@@ -744,22 +595,14 @@ fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
} }
/// One line's sequence number, or `None` when the line is not one of ours. /// One line's sequence number, or `None` when the line is not one of ours.
///
/// A hand-rolled scan rather than a JSON parse, so this module carries no
/// parser and stays testable with no server: the seq is the first field the
/// server writes, so the first match is the top-level one.
pub fn seq_of(line: &str) -> Option<u64> { pub fn seq_of(line: &str) -> Option<u64> {
find_number_field(line, "seq") find_number_field(line, "seq")
} }
/// Whether a line is one streamed piece of a reply, which is what makes a
/// run of them one row.
pub fn is_delta(line: &str) -> bool { pub fn is_delta(line: &str) -> bool {
find_string_field(line, "type").as_deref() == Some("assistantText") find_string_field(line, "type").as_deref() == Some("assistantText")
} }
/// The value of `"key":N` (any amount of whitespace around the colon), or
/// `None`. Mirrors `Regex(""""seq"\s*:\s*(\d+)""")`'s first match.
fn find_number_field(line: &str, key: &str) -> Option<u64> { fn find_number_field(line: &str, key: &str) -> Option<u64> {
let pattern = format!("\"{key}\""); let pattern = format!("\"{key}\"");
let at = line.find(&pattern)?; let at = line.find(&pattern)?;
@@ -777,8 +620,6 @@ fn find_number_field(line: &str, key: &str) -> Option<u64> {
} }
} }
/// The value of `"key":"..."`, or `None`. Mirrors
/// `Regex(""""type"\s*:\s*"([^"]*)"""")`'s first match.
fn find_string_field(line: &str, key: &str) -> Option<String> { fn find_string_field(line: &str, key: &str) -> Option<String> {
let pattern = format!("\"{key}\""); let pattern = format!("\"{key}\"");
let at = line.find(&pattern)?; let at = line.find(&pattern)?;
@@ -789,18 +630,11 @@ fn find_string_field(line: &str, key: &str) -> Option<String> {
Some(after_quote[..end].to_string()) Some(after_quote[..end].to_string())
} }
/// How much of a file is read at a time when walking it backwards. One
/// block covers a page of a transcript comfortably, and the walk stops as
/// soon as the caller has what it asked for.
const READ_BLOCK: usize = 64 * 1024; const READ_BLOCK: usize = 64 * 1024;
/// Calls `on_line` with each non-blank line of `file`, **newest first**, /// Calls `on_line` with each non-blank line of `file`, **newest first**,
/// along with the byte offset it starts at, until `on_line` answers false. /// along with the byte offset it starts at, until `on_line` answers false.
/// ///
/// Every question the cache is asked is about the newest end of a chunk,
/// and a live run reaches the size of the conversation, so reading forwards
/// means reading a transcript to answer with the last eighty lines of it.
///
/// Splitting on bytes is safe because the separator is `\n`, which cannot /// Splitting on bytes is safe because the separator is `\n`, which cannot
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A /// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
/// missing file yields nothing. /// missing file yields nothing.
@@ -823,7 +657,6 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
} }
let mut buffer = block; let mut buffer = block;
buffer.extend_from_slice(&pending); buffer.extend_from_slice(&pending);
// `buffer` is now `block` followed by `pending`; walk it backwards.
let mut line_end = buffer.len(); let mut line_end = buffer.len();
let mut at = buffer.len() as isize - 1; let mut at = buffer.len() as isize - 1;
while at >= 0 { while at >= 0 {
@@ -840,20 +673,12 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
pending = buffer[..line_end].to_vec(); pending = buffer[..line_end].to_vec();
unread = start; unread = start;
} }
// The first line of a file has no newline before it to be found.
let first = String::from_utf8_lossy(&pending); let first = String::from_utf8_lossy(&pending);
if !first.trim().is_empty() { if !first.trim().is_empty() {
on_line(0, &first); on_line(0, &first);
} }
} }
/// Drops a final line that is not one of ours, by truncating the file to
/// where it starts.
///
/// This app having died mid-write is the one kind of damage that is
/// repaired rather than discarded: the tail of an append-only file is the
/// only place a partial line can be. A second bad line is not this, and is
/// left for the read path to notice.
fn repair_tail(file: &Path) -> io::Result<()> { fn repair_tail(file: &Path) -> io::Result<()> {
let mut truncate_to: Option<u64> = None; let mut truncate_to: Option<u64> = None;
each_line_backwards(file, |offset, line| { each_line_backwards(file, |offset, line| {
@@ -869,9 +694,6 @@ fn repair_tail(file: &Path) -> io::Result<()> {
Ok(()) Ok(())
} }
/// Runs `body`, translating an I/O or permission failure into `if_broken`
/// and a warning -- the disk half of [`SessionCache::guard`], shared with
/// [`TranscriptCache`]'s own maintenance.
fn guard_io<T>( fn guard_io<T>(
if_broken: T, if_broken: T,
warn: &(impl Fn(&str) + ?Sized), warn: &(impl Fn(&str) + ?Sized),
@@ -886,10 +708,6 @@ fn guard_io<T>(
} }
} }
/// Sets a path's modified time, without pulling in a crate for it: a single
/// `utimensat`-backed call would be one more platform-specific dependency
/// for one call site, so this touches the file instead, which every
/// filesystem this runs on updates the mtime for.
fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> { fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> {
use std::io::Write; use std::io::Write;
// Rewriting a marker file's contents (rather than the directory itself, // Rewriting a marker file's contents (rather than the directory itself,
@@ -920,7 +738,6 @@ mod tests {
}) })
} }
/// Like `cache`, but also hands back the messages it warned with.
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) { fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default(); let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
let said2 = said.clone(); let said2 = said.clone();
@@ -996,8 +813,6 @@ mod tests {
}) })
); );
assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]); assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]);
// More than there is is what there is, which is a short opening
// window and not a failure.
assert_eq!(session.newest(80).len(), 3); assert_eq!(session.newest(80).len(), 3);
} }
@@ -1009,8 +824,6 @@ mod tests {
for seq in 1..=3u64 { for seq in 1..=3u64 {
session.append(&tool_line(seq), seq); session.append(&tool_line(seq), seq);
} }
// What a `reset` looks like from here: the next event is not the
// one after the last.
session.append(&tool_line(90), 90); session.append(&tool_line(90), 90);
session.flush(); session.flush();
@@ -1052,14 +865,11 @@ mod tests {
} }
session.flush(); session.flush();
// Adjacent: its end is the open chunk's first.
let page: Vec<String> = (60..100u64).map(tool_line).collect(); let page: Vec<String> = (60..100u64).map(tool_line).collect();
assert!(session.store_page(&page, 60, 100, true)); assert!(session.store_page(&page, 60, 100, true));
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99])); assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60)); assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60));
// Behind a gap: kept on disk, because paging usually closes the
// gap, but never served across it.
let page2: Vec<String> = (1..10u64).map(tool_line).collect(); let page2: Vec<String> = (1..10u64).map(tool_line).collect();
assert!(session.store_page(&page2, 1, 10, true)); assert!(session.store_page(&page2, 1, 10, true));
assert_eq!(session.page(10, 5, false), None); assert_eq!(session.page(10, 5, false), None);
@@ -1095,8 +905,6 @@ mod tests {
} }
session.flush(); session.flush();
// At or below where the run starts, so what the reader is
// scrolling into is the server's.
assert_eq!(session.page(100, 40, true), None); assert_eq!(session.page(100, 40, true), None);
assert_eq!(session.page(40, 40, true), None); assert_eq!(session.page(40, 40, true), None);
assert_eq!(cache.session("never-visited").page(100, 40, true), None); assert_eq!(cache.session("never-visited").page(100, 40, true), None);
@@ -1121,8 +929,6 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let cache = cache(temp.path()); let cache = cache(temp.path());
let session = cache.session("s"); let session = cache.session("s");
// Two replies of three deltas each, split by a tool call: the same
// fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`.
let lines = vec![ let lines = vec![
delta(1), delta(1),
delta(2), delta(2),
@@ -1137,14 +943,8 @@ mod tests {
session.append(&tool_line(9), 9); session.append(&tool_line(9), 9);
session.flush(); session.flush();
// Three rows: the tool call at 8, the run 5..7, and the tool call
// at 4. The cut lands between rows, so the older run is not
// started.
assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8])); assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8]));
// One row is one whole run, however many deltas it is made of.
assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8])); assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8]));
// A page of lines counts lines, which is what the anchor restore
// asks for.
assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8])); assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8]));
} }
@@ -1163,10 +963,7 @@ mod tests {
session.append(&tool_line(10), 10); session.append(&tool_line(10), 10);
session.flush(); session.flush();
// A run straddling the boundary is one row, as it will be once folded.
assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10])); assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10]));
// Asking for more rows than the suffix holds is a short page, not a
// failure and not a claim that the conversation starts here.
assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect())); assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect()));
} }
@@ -1190,13 +987,9 @@ mod tests {
session.append(&tool_line(90), 90); session.append(&tool_line(90), 90);
session.flush(); session.flush();
// The run behind the gap, which is what makes the fetched page
// adjacent to it.
assert_eq!(session.covered_up_to(90), Some(40)); assert_eq!(session.covered_up_to(90), Some(40));
assert_eq!(session.covered_up_to(41), Some(40)); assert_eq!(session.covered_up_to(41), Some(40));
assert_eq!(session.covered_up_to(10), Some(10)); assert_eq!(session.covered_up_to(10), Some(10));
// Nothing at or below the oldest chunk's start, so the page is
// bounded only by its limit.
assert_eq!(session.covered_up_to(9), None); assert_eq!(session.covered_up_to(9), None);
} }
@@ -1212,9 +1005,6 @@ mod tests {
&(1..10u64).map(tool_line).collect::<Vec<_>>(), &(1..10u64).map(tool_line).collect::<Vec<_>>(),
); );
// Only reachable by dying between closing one live run and opening
// the next, and there is no cursor to be read off a coalesced line
// -- so the open is a cold one.
assert_eq!(session.tail(), None); assert_eq!(session.tail(), None);
assert!(!dir_of(temp.path(), "s").exists()); assert!(!dir_of(temp.path(), "s").exists());
} }
@@ -1243,7 +1033,6 @@ mod tests {
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(), fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
format!("{}\n{}\n", tool_line(1), tool_line(2)) format!("{}\n{}\n", tool_line(1), tool_line(2))
); );
// And the run continues from where the good tail left off.
session.append(&tool_line(3), 3); session.append(&tool_line(3), 3);
session.flush(); session.flush();
assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]); assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]);
@@ -1261,7 +1050,6 @@ mod tests {
&[tool_line(1), "not ours".to_string(), tool_line(3)], &[tool_line(1), "not ours".to_string(), tool_line(3)],
); );
// Not seen by the tail, which reads the newest line and stops.
assert_eq!( assert_eq!(
session.tail(), session.tail(),
Some(CachedTail { Some(CachedTail {
@@ -1269,8 +1057,6 @@ mod tests {
line: tool_line(3) line: tool_line(3)
}) })
); );
// Reached by a read that walks past it: what is served is nothing,
// and the session opens cold from here on.
assert_eq!(session.newest(80), Vec::<String>::new()); assert_eq!(session.newest(80), Vec::<String>::new());
assert!(!dir_of(temp.path(), "s").exists()); assert!(!dir_of(temp.path(), "s").exists());
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged"))); assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
@@ -1298,9 +1084,6 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let cache = cache(temp.path()); let cache = cache(temp.path());
let session = cache.session("s"); let session = cache.session("s");
// Well past the 64 kB block the backwards reader takes at a time,
// so a page has to be stitched across several of them -- including
// a line that straddles a boundary.
let padding = "x".repeat(300); let padding = "x".repeat(300);
let lines: Vec<String> = (1..=500u64) let lines: Vec<String> = (1..=500u64)
.map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#)) .map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#))
@@ -1310,8 +1093,6 @@ mod tests {
assert_eq!(session.tail().unwrap().seq, 500); assert_eq!(session.tail().unwrap().seq, 500);
assert_eq!(session.newest(80), lines[420..].to_vec()); assert_eq!(session.newest(80), lines[420..].to_vec());
assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec())); assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec()));
// And a non-ASCII line, whose bytes a naive split could cut through
// a character.
let accented = let accented =
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string(); r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
session.append(&accented, 501); session.append(&accented, 501);
@@ -1333,15 +1114,10 @@ mod tests {
let when = let when =
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64); std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64);
filetime_set_modified(&dir_of(temp.path(), id), when).unwrap(); filetime_set_modified(&dir_of(temp.path(), id), when).unwrap();
// The mtime touch above always sets "now", not `when` (see its
// own doc) -- space the three writes out in real time instead,
// since only relative order matters to eviction.
std::thread::sleep(std::time::Duration::from_millis(10)); std::thread::sleep(std::time::Duration::from_millis(10));
} }
let each = dir_size(&dir_of(temp.path(), "old")); let each = dir_size(&dir_of(temp.path(), "old"));
// Room for two of the three, so the oldest goes -- and the session
// being read never does, however long ago it was last touched.
cache.evict_to_budget("open", each * 2); cache.evict_to_budget("open", each * 2);
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443")) let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
.unwrap() .unwrap()
@@ -1394,8 +1170,6 @@ mod tests {
session.purge(); session.purge();
assert_eq!(session.bytes(), 0); assert_eq!(session.bytes(), 0);
assert_eq!(session.tail(), None); assert_eq!(session.tail(), None);
// And the session is usable again straight afterwards, which is
// what a reload does next.
session.append(&tool_line(9), 9); session.append(&tool_line(9), 9);
session.flush(); session.flush();
assert_eq!(seqs_vec(&session.newest(80)), vec![9]); assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
@@ -1,32 +1,5 @@
//! What the transcript renders: the event stream folded into displayable
//! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s
//! non-Compose half (`TranscriptRow`, `groupToolRuns`).
//!
//! Events are the only data source, and there is deliberately no second
//! shape for history to drift from: a page fetched backwards, a live
//! frame, and a line read out of the transcript cache are all the same
//! events through the same fold.
//!
//! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into
//! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer
//! exists to bound how much a lazy list composes per frame, which is a
//! fact about the UI framework drawing it, not about the transcript. See
//! `CLIENT_CORE.md`.
//!
//! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate
//! deserializes straight into [`event_model::Event`], which has no
//! `Unknown` catch-all -- an event type this build does not recognise
//! fails to parse rather than degrading to a placeholder row. Closing that
//! gap means giving `event_model::Event` its own forward-compatible
//! variant, which is a shared-model decision for both sides of the wire
//! and is deliberately left for whoever picks this up next (see
//! `CLIENT_CORE.md`).
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus}; use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
/// A question this build has already asked the reader about, with what was
/// answered so far -- distinct from [`QuestionOption`], which is what could
/// be chosen.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct QuestionCard { pub struct QuestionCard {
pub seq: u64, pub seq: u64,
@@ -38,24 +11,14 @@ pub struct QuestionCard {
pub answers: Vec<String>, pub answers: Vec<String>,
} }
/// A tool call cannot be recognised as `AskUserQuestion` from a bare
/// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption
/// logic name it explicitly.
pub const ASK_USER_QUESTION: &str = "AskUserQuestion"; pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
/// This item's identity in the list: a `Seq` for everything with no
/// identity of its own, `RunId` for a tool call (which keeps one across
/// however many calls join or leave its run), matching `TranscriptItem.key`
/// in the Kotlin original.
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ItemKey { pub enum ItemKey {
Seq(u64), Seq(u64),
RunId(String), RunId(String),
} }
/// One row of the transcript, folded from [`Event`]s. See each variant's
/// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this
/// doc only says what changed in translation.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum TranscriptItem { pub enum TranscriptItem {
UserMsg { UserMsg {
@@ -66,8 +29,6 @@ pub enum TranscriptItem {
AssistantMsg { AssistantMsg {
seq: u64, seq: u64,
text: String, text: String,
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
/// Kotlin doc for why the split it licenses matters.
settled: bool, settled: bool,
}, },
ToolRun { ToolRun {
@@ -95,9 +56,6 @@ pub enum TranscriptItem {
seq: u64, seq: u64,
r#ref: String, r#ref: String,
}, },
/// A message from another agent. `arrived` is this row's own identity
/// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which
/// [`place_peer_note`] may set to the turn's opening seq instead.
PeerNote { PeerNote {
seq: u64, seq: u64,
from: String, from: String,
@@ -108,8 +66,6 @@ pub enum TranscriptItem {
seq: u64, seq: u64,
text: String, text: String,
}, },
/// Placeholder for an event kind this build could not fold -- see the
/// module doc's "known gap".
Note { Note {
seq: u64, seq: u64,
text: String, text: String,
@@ -201,14 +157,10 @@ fn update_tool(
.collect() .collect()
} }
/// Whether a status means the session is still doing something, mirroring
/// `sessionWorking` in `Events.kt`.
pub fn session_working(status: SessionStatus) -> bool { pub fn session_working(status: SessionStatus) -> bool {
matches!(status, SessionStatus::Running | SessionStatus::Compacting) matches!(status, SessionStatus::Running | SessionStatus::Compacting)
} }
/// A status saying the session stopped working is the moment its newest
/// reply is finished.
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> { fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
if session_working(status) { if session_working(status) {
return items.to_vec(); return items.to_vec();
@@ -223,9 +175,6 @@ fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<Transcri
items items
} }
/// A peer message goes above the turn it started, not where it happened to
/// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning;
/// `turn_start` is `Event::PeerMessage`'s own field of that name.
fn place_peer_note( fn place_peer_note(
items: &[TranscriptItem], items: &[TranscriptItem],
seq: u64, seq: u64,
@@ -264,8 +213,6 @@ fn place_peer_note(
out out
} }
/// The calls the note now sits in front of, renamed if they were sharing a
/// run with the calls behind it. See the Kotlin `splitRun`'s doc.
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> { fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
let Some(TranscriptItem::ToolRun { let Some(TranscriptItem::ToolRun {
run_id: first_run_id, run_id: first_run_id,
@@ -299,14 +246,6 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
out out
} }
/// Puts a page of older items in front of the ones already loaded, healing
/// whatever the page boundary cut in two. Ported from `TranscriptItems.kt`'s
/// `joinPages`.
///
/// Two things straddle a boundary: a tool call separated from its result,
/// and a message separated from the rest of itself. Both were one thing
/// before the transcript was cut into pages.
///
/// A boundary lands wherever it lands, and roughly half the time that is /// A boundary lands wherever it lands, and roughly half the time that is
/// between a call and its result. The newer page then holds a `ToolEnd` /// between a call and its result. The newer page then holds a `ToolEnd`
/// whose start it never saw, which `fold_event` draws as a row of its own /// whose start it never saw, which `fold_event` draws as a row of its own
@@ -319,23 +258,12 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
/// exactly what a page boundary destroys. The older row wins on what a /// exactly what a page boundary destroys. The older row wins on what a
/// start knows and the newer on what an end knows, which is the only way /// start knows and the newer on what an end knows, which is the only way
/// round that loses nothing. /// round that loses nothing.
///
/// The third thing is the *run*, and it is the one the Kotlin original used
/// to miss (AGENTS.md's "things that have bitten"): every page ends up
/// here, but `adopt_run` must run on *every* join, not only the one where a
/// split call was found -- a boundary landing cleanly between two finished
/// calls, which is most of them, would otherwise leave the older page's
/// calls under the run name they were folded with. On screen: one run of
/// tool calls drawn as two groups, with the seam wherever the reader
/// happened to have paged.
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> { pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
let (older, newer) = heal_split_message(earlier, later); let (older, newer) = heal_split_message(earlier, later);
let started_earlier: std::collections::HashSet<&str> = older let started_earlier: std::collections::HashSet<&str> = older
.iter() .iter()
.filter_map(TranscriptItem::as_tool_run) .filter_map(TranscriptItem::as_tool_run)
.collect(); .collect();
// Owned rather than borrowed from `newer`: `kept` below needs to consume `newer` by
// value, and a map borrowing it would keep that alive.
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
.iter() .iter()
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone()))) .filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
@@ -374,9 +302,6 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
output: output.clone(), output: output.clone(),
done, done,
failed, failed,
// Kept from both halves: a question or an image can be
// attached to either, depending on which side of the
// boundary its event fell.
asks: row_asks.into_iter().chain(half_asks.clone()).collect(), asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
images: row_images.into_iter().chain(half_images.clone()).collect(), images: row_images.into_iter().chain(half_images.clone()).collect(),
} }
@@ -411,18 +336,10 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
out out
} }
/// Rejoins a message the page boundary cut, and hands back the two pages to
/// concatenate. Ported from `TranscriptItems.kt`'s `healSplitMessage`.
///
/// `fold_event` never leaves two assistant messages next to each other /// `fold_event` never leaves two assistant messages next to each other
/// inside one page, so two meeting at a join are always the two halves of /// inside one page, so two meeting at a join are always the two halves of
/// one reply, and leaving them apart drew a single answer as two with a /// one reply, and leaving them apart drew a single answer as two with a
/// paragraph break through the middle of a sentence. /// paragraph break through the middle of a sentence.
///
/// The newer half keeps its identity, for the reason `adopt_run`'s doc
/// gives. It grows by what the older half brings, which is safe here and
/// nowhere else -- the join is at the oldest end of what is loaded, so the
/// growth extends off the top of the screen.
fn heal_split_message( fn heal_split_message(
earlier: &[TranscriptItem], earlier: &[TranscriptItem],
later: &[TranscriptItem], later: &[TranscriptItem],
@@ -450,21 +367,10 @@ fn heal_split_message(
(earlier[..earlier.len() - 1].to_vec(), newer) (earlier[..earlier.len() - 1].to_vec(), newer)
} }
/// Hands the older calls at the join the name of the run they are joining.
/// Ported from `TranscriptItems.kt`'s `adoptRun`.
///
/// The two pages were folded separately, so a run split by the boundary
/// came back as two runs with two names. Naming the joined run after the
/// *older* half would be the obvious way round and is wrong: the newer half
/// is the part already on screen, and renaming it is renaming the row the
/// reader is looking at, which is how a list loses its anchor.
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> { fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else { let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
return earlier.to_vec(); return earlier.to_vec();
}; };
// A question is in a run of its own on both sides of the join, the same as it would be
// had the two pages been folded as one. Without this the heal would merge a group
// straight through the row the reader was asked something on.
if tool == ASK_USER_QUESTION { if tool == ASK_USER_QUESTION {
return earlier.to_vec(); return earlier.to_vec();
} }
@@ -494,10 +400,6 @@ fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<Transc
out out
} }
/// Folds one transcript event onto `items`, the way `foldEvent` does in
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
/// for the one difference from the Kotlin original (no `Unknown` fallback
/// at the parse layer).
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> { pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
let seq = entry.seq; let seq = entry.seq;
match &entry.event { match &entry.event {
@@ -512,9 +414,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
}); });
items items
} }
// `MessageTaken` is folded into `UserMessage` by the manager before
// it reaches a phone (see `PLAN.md`); if one arrives here anyway
// (a raw transcript line, say), it reads the same way.
Event::MessageTaken { Event::MessageTaken {
text, attachments, .. text, attachments, ..
} => { } => {
@@ -527,12 +426,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
items items
} }
Event::AssistantText { delta } => { Event::AssistantText { delta } => {
// Deltas accumulate into the message they're streaming, which
// keeps the seq of the *first* of them: a row whose identity
// changed with every delta would be a new row every frame.
// "A message growing again is not finished" -- whatever a
// status said in between -- is why this always clears
// `settled` rather than preserving it.
if let Some(TranscriptItem::AssistantMsg { if let Some(TranscriptItem::AssistantMsg {
seq: first_seq, seq: first_seq,
text, text,
@@ -704,7 +597,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
}); });
items items
} }
// Screen-level state, not transcript rows.
Event::CommandQueued { .. } Event::CommandQueued { .. }
| Event::MessageQueued { .. } | Event::MessageQueued { .. }
| Event::MessageDropped { .. } | Event::MessageDropped { .. }
@@ -769,9 +661,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
} }
} }
/// What became of one tool call -- every state a card has to be able to
/// draw, including the two that are not answers.
///
/// The pair this enum exists for is [`ToolState::Succeeded`] against /// The pair this enum exists for is [`ToolState::Succeeded`] against
/// [`ToolState::NoResult`]. A call that finished having printed nothing /// [`ToolState::NoResult`]. A call that finished having printed nothing
/// and a call whose result never arrived both leave an empty `output`, /// and a call whose result never arrived both leave an empty `output`,
@@ -780,34 +669,18 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
/// turn ended before anything came back. /// turn ended before anything came back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolState { pub enum ToolState {
/// Started, no result yet, and the session is still working -- the
/// ordinary state of a call in flight.
Running, Running,
/// Stopped on the reader: a permission or question this call carries /// Stopped on the reader: a permission or question this call carries
/// has not been answered, so nothing is happening until somebody /// has not been answered, so nothing is happening until somebody
/// answers it. Distinct from [`Self::Running`] because whose move it /// answers it. Distinct from [`Self::Running`] because whose move it
/// is differs, which is the Compose card's "your turn". /// is differs, which is the Compose card's "your turn".
Deciding, Deciding,
/// A result arrived and the tool did not report a failure.
Succeeded, Succeeded,
/// A result arrived and the tool reported that the call failed
/// (`is_error`).
Failed, Failed,
/// No result ever arrived and the session is not working any more --
/// the turn was interrupted, or the process went away. Not a verdict
/// on the call: it says only that nobody found out.
NoResult, NoResult,
} }
impl ToolState { impl ToolState {
/// The state of one call. `session_working` is
/// [`session_working`]'s answer for the session this call is in --
/// the only thing here that is not a property of the call itself, and
/// what separates "still running" from "never came back".
///
/// Written once, over the fields rather than per call site, because
/// the five states are decided by four conditions and every place
/// that re-derived a subset of them got a different subset.
pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> { pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> {
let TranscriptItem::ToolRun { let TranscriptItem::ToolRun {
done, failed, asks, .. done, failed, asks, ..
@@ -820,9 +693,6 @@ impl ToolState {
"a call cannot have failed before its result arrived" "a call cannot have failed before its result arrived"
); );
Some(if asks.iter().any(|ask| ask.answers.is_empty()) { Some(if asks.iter().any(|ask| ask.answers.is_empty()) {
// Ahead of `done`: a call waiting on permission has not
// finished either, and which of the two the reader is being
// told about is the one they can act on.
Self::Deciding Self::Deciding
} else if !*done { } else if !*done {
match session_working { match session_working {
@@ -837,14 +707,9 @@ impl ToolState {
} }
} }
/// 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.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum TranscriptRow { pub enum TranscriptRow {
Single(TranscriptItem), Single(TranscriptItem),
/// Two or more calls with nothing between them.
Tools(Vec<TranscriptItem>), Tools(Vec<TranscriptItem>),
} }
@@ -864,9 +729,6 @@ impl TranscriptRow {
} }
} }
/// Runs of adjacent tool calls become one row; everything else passes
/// through. See the Kotlin `groupRuns`'s doc for why grouping is by the
/// run each call names rather than by adjacency worked out here.
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> { pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
let mut rows = Vec::new(); let mut rows = Vec::new();
let mut run: Vec<TranscriptItem> = Vec::new(); let mut run: Vec<TranscriptItem> = Vec::new();
@@ -902,15 +764,6 @@ pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
rows rows
} }
/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s
/// `Vec<Value>`) into the flat item list this module works over. A line
/// this build can't parse fails the whole page rather than being skipped --
/// CODE_RULES's "an enumeration must be able to say 'it broke'" -- since
/// silently dropping one event could hide, say, a user message that then
/// looks like it was never sent. Moved here from `desktop-app`'s `app.rs`
/// (RUST.md's E4) when the Android transcript client (I5) needed the same
/// fold: "write the logic once" applies to any caller embedding
/// `transcript-ui` against a live server, not just the first one.
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> { pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
let mut items = Vec::new(); let mut items = Vec::new();
for value in values { for value in values {
@@ -922,13 +775,6 @@ pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, St
Ok(items) Ok(items)
} }
/// The wire `seq` a raw transcript line carries -- the live-stream resume
/// cursor after loading a page must be this, not a folded item's `seq()`.
/// A folded `AssistantMsg` keeps the seq of the *first* delta it
/// accumulated (`fold_event`'s own doc), so resuming from that seq would
/// re-deliver every delta already folded into it, duplicating the tail of
/// a reply that was mid-stream when the page was fetched -- found via a
/// real screenshot in E4 (RUST.md), where the assistant's line doubled.
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> { pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
value.get("seq")?.as_u64() value.get("seq")?.as_u64()
} }
@@ -1161,12 +1007,6 @@ mod tests {
obj obj
} }
/// The regression for a bug a real `run-headless.sh` screenshot found
/// in `desktop-app` (E4, RUST.md): resuming the live stream from the
/// last *item's* seq re-delivers the deltas already folded into a
/// still-open assistant message, doubling its tail. `raw_seq` of the
/// last wire line must be the true high-water mark instead, which for a
/// run of deltas is higher than every item's own `seq()`.
#[test] #[test]
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() { fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
let values = vec![ let values = vec![
@@ -1260,13 +1100,6 @@ mod tests {
) )
} }
/// AGENTS.md's "things that have bitten": `joinPages` used to run
/// `adoptRun` only on the path where a *split* call was found, so a
/// boundary landing cleanly between two already-finished calls -- most
/// of them -- left the older page's calls under the run name they were
/// folded with, drawing one run of tool calls as two groups. Two
/// finished, unrelated calls (no id in common) must still end up under
/// one run name after the join.
#[test] #[test]
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() { fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]); let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
@@ -1346,9 +1179,6 @@ mod tests {
); );
} }
/// A question is in a run of its own on both sides of a join -- healing
/// must never rename the run of calls the reader was asked something
/// on, the same rule `splitRun` enforces for a live turn boundary.
#[test] #[test]
fn adopt_run_never_renames_into_a_question_row() { fn adopt_run_never_renames_into_a_question_row() {
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]); let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
@@ -1372,10 +1202,6 @@ mod tests {
} }
} }
/// [`ToolState`] is what a card colours itself by, so each of its five
/// states is asserted from the events that actually produce it rather than
/// from a hand-built item -- a mapping that agreed with a fixture and
/// disagreed with the fold would be invisible until it was on screen.
#[cfg(test)] #[cfg(test)]
mod tool_state_tests { mod tool_state_tests {
use super::*; use super::*;
@@ -1433,10 +1259,6 @@ mod tool_state_tests {
); );
} }
/// The pair this enum exists for. Both calls have an empty `output`
/// and nothing else distinguishes them, so a card that only looked at
/// the text would draw the interrupted one as a call that ran fine and
/// printed nothing.
#[test] #[test]
fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() { fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() {
assert_eq!( assert_eq!(
@@ -1451,9 +1273,6 @@ mod tool_state_tests {
); );
} }
/// The same call, mid-turn: still running rather than abandoned. The
/// only thing separating the two is the session's own status, which is
/// why `of` takes it.
#[test] #[test]
fn no_result_while_the_session_works_is_still_running() { fn no_result_while_the_session_works_is_still_running() {
assert_eq!(state_of(&[start("a")], true), ToolState::Running); assert_eq!(state_of(&[start("a")], true), ToolState::Running);
@@ -1483,8 +1302,6 @@ mod tool_state_tests {
answers: vec!["Allow".to_string()], answers: vec!["Allow".to_string()],
}, },
); );
// Ahead of both "still running" and "no result": the reader can
// act on this one, and cannot act on either of those.
assert_eq!( assert_eq!(
state_of(&[start("a"), asking.clone()], true), state_of(&[start("a"), asking.clone()], true),
ToolState::Deciding ToolState::Deciding
@@ -1,33 +1,9 @@
//! Where a session screen gets a transcript from: this phone's copy first,
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
//!
//! One seam rather than a cache the screen has to remember to consult.
//! Everything fetched before is asked of this, and everything the server
//! sends is written into the cache on the way past, so a caller never
//! learns which side answered. The one rule worth keeping in mind: the
//! cache is never load-bearing. Every read here has a network path beside
//! it producing the same result.
//!
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
//! ability to close a live stream from another thread. Both are wall-clock
//! and thread-lifetime concerns that belong to whatever runtime the caller
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
//! scope) rather than to this pure logic -- `follow` below is the same
//! decorator shape `iris/desktop-app/src/app.rs` and
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
//! `event_stream::follow_session_events`, just with the cache write built
//! in so a future caller does not have to repeat it a third time.
use event_model::SeqEvent; use event_model::SeqEvent;
use crate::api::{ApiClient, ApiError, Transport}; use crate::client::api::{ApiClient, ApiError, Transport};
use crate::event_stream::{self, StreamItem}; use crate::client::event_stream::{self, StreamItem};
use crate::transcript_cache::SessionCache; use crate::client::transcript_cache::SessionCache;
/// How many events a session screen opens with, cached or fetched.
///
/// The server's own default page size, named here because the cached /// The server's own default page size, named here because the cached
/// opening has to be the same size as the fetched one -- a reader must not /// opening has to be the same size as the fetched one -- a reader must not
/// get a shorter first screen for having been here before (`OPENING_WINDOW` /// get a shorter first screen for having been here before (`OPENING_WINDOW`
@@ -49,9 +25,6 @@ impl std::fmt::Display for ParseError {
} }
impl std::error::Error for ParseError {} impl std::error::Error for ParseError {}
/// Either half of what can go wrong asking for a page: the network, or a
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
/// read.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum PageError { pub enum PageError {
Api(ApiError), Api(ApiError),
@@ -70,23 +43,9 @@ impl From<ParseError> for PageError {
} }
} }
/// What [`TranscriptSource::page`] found, kept as two states rather than
/// one possibly-empty list.
///
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
/// empty [`Self::Events`] means "this conversation has no more history",
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
/// question could not be asked yet, which it must not. Collapsing the two
/// into an empty `Vec` puts the bug back, because the caller cannot tell
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
/// same silently.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum OlderPage { pub enum OlderPage {
/// The events before the cursor, oldest first. Empty means the start
/// of the conversation has been reached.
Events(Vec<SeqEvent>), Events(Vec<SeqEvent>),
/// Nothing is loaded, so there was no cursor to page back from
/// (`before == 0`). Not an answer about the conversation at all.
NothingLoaded, NothingLoaded,
} }
@@ -94,8 +53,6 @@ fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}"))) serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
} }
/// This phone's copy of one session's transcript, plus the server it
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
pub struct TranscriptSource<T: Transport> { pub struct TranscriptSource<T: Transport> {
api: ApiClient<T>, api: ApiClient<T>,
session_id: String, session_id: String,
@@ -113,11 +70,6 @@ impl<T: Transport> TranscriptSource<T> {
/// The cached opening window, or `None` when there is nothing usable /// The cached opening window, or `None` when there is nothing usable
/// to draw. /// to draw.
///
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
/// whole point of the feature: the rows are on screen while the check
/// that they are still the server's rows is in flight, and a failed
/// check replaces them exactly as a reset does.
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> { pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
self.cache.tail()?; self.cache.tail()?;
let lines = self.cache.newest(limit); let lines = self.cache.newest(limit);
@@ -126,9 +78,6 @@ impl<T: Transport> TranscriptSource<T> {
} }
match lines.iter().map(|l| parse_line(l)).collect() { match lines.iter().map(|l| parse_line(l)).collect() {
Ok(events) => Some(events), Ok(events) => Some(events),
// A line this build cannot read at all, which the cache's own checks cannot
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
// open.
Err(ParseError(_)) => { Err(ParseError(_)) => {
self.cache.purge(); self.cache.purge();
None None
@@ -136,9 +85,6 @@ impl<T: Transport> TranscriptSource<T> {
} }
} }
/// Whether the server's event at the cached cursor is still the cached
/// one.
///
/// A caller must not resume a live stream from a cached seq unless it /// A caller must not resume a live stream from a cached seq unless it
/// is the same conversation: a transcript is append-only in ordinary /// is the same conversation: a transcript is append-only in ordinary
/// use, but the file backing it can be replaced or truncated (a /// use, but the file backing it can be replaced or truncated (a
@@ -151,15 +97,10 @@ impl<T: Transport> TranscriptSource<T> {
/// `Ok(false)` purges the cache and means "open cold". `Err` is the /// `Ok(false)` purges the cache and means "open cold". `Err` is the
/// server not being askable, which is neither: the cached rows stay /// server not being askable, which is neither: the cached rows stay
/// on screen and the caller tries again on its own reconnect schedule. /// on screen and the caller tries again on its own reconnect schedule.
///
/// What this cannot see is a line changed in the middle of the file
/// with the tail intact -- that is what a full reload is for.
pub fn probe(&self) -> Result<bool, ApiError> { pub fn probe(&self) -> Result<bool, ApiError> {
let Some(tail) = self.cache.tail() else { let Some(tail) = self.cache.tail() else {
return Ok(false); return Ok(false);
}; };
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
// event *at* the cursor when the server still has one there.
let page = self.api.fetch_transcript_lines( let page = self.api.fetch_transcript_lines(
&self.session_id, &self.session_id,
Some(tail.seq + 1), Some(tail.seq + 1),
@@ -177,9 +118,6 @@ impl<T: Transport> TranscriptSource<T> {
Ok(matches) Ok(matches)
} }
/// Today's opening fetch, kept as the start of the live run. Only
/// called when the cache has nothing to open with, or when
/// [`Self::probe`] said what it had was not the server's.
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> { pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
let page = let page =
self.api self.api
@@ -193,21 +131,6 @@ impl<T: Transport> TranscriptSource<T> {
/// The page before `before`: from the cache when it holds it, /// The page before `before`: from the cache when it holds it,
/// otherwise from the server bounded by what the cache already has. /// otherwise from the server bounded by what the cache already has.
///
/// The server bound (`after`) is what keeps the cache worth having. A
/// coalesced page reaches back as far as its row count takes it -- a
/// single reply is hundreds of lines -- so a page fetched after the
/// reader has been away could run straight past the cached run and
/// overlap it, and an overlapping page cannot be stored. Told where
/// this phone's copy starts, the server stops there instead.
///
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
/// the cache or the server anything -- see AGENTS.md's "things that
/// have bitten": there is no event before the first one, so the
/// request is not a harmless no-op, and its empty answer is
/// indistinguishable from having reached the start of history.
/// Guarded here rather than left to every caller, because it is a fact
/// about the question, not about who is asking it.
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> { pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
if before == 0 { if before == 0 {
return Ok(OlderPage::NothingLoaded); return Ok(OlderPage::NothingLoaded);
@@ -228,9 +151,6 @@ impl<T: Transport> TranscriptSource<T> {
after, after,
)?; )?;
if let Some((_, first_event)) = page.first() { if let Some((_, first_event)) = page.first() {
// `before` rather than the newest line's seq: a coalesced page covers
// everything up to the cursor it was asked with, and nothing in its lines
// says so.
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect(); let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
self.cache self.cache
.store_page(&lines, first_event.seq, before, coalesce); .store_page(&lines, first_event.seq, before, coalesce);
@@ -240,9 +160,6 @@ impl<T: Transport> TranscriptSource<T> {
)) ))
} }
/// [`event_stream::follow_session_events`], with every frame written to
/// the cache before `on_item` sees it.
///
/// Before, so that an event held back for a reader who is scrolled /// Before, so that an event held back for a reader who is scrolled
/// away is already on disk -- what the cache holds is what the server /// away is already on disk -- what the cache holds is what the server
/// sent, not what a screen has got round to drawing. Flushed on each /// sent, not what a screen has got round to drawing. Flushed on each
@@ -284,16 +201,11 @@ impl<T: Transport> TranscriptSource<T> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::api::{Body, RawResponse}; use crate::client::api::{Body, RawResponse};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::io::Read; use std::io::Read;
use std::sync::Mutex; use std::sync::Mutex;
/// A transport that answers fixed bodies in call order, and records
/// every path it was asked for -- so a test can assert *how many*
/// requests a method made, which is the point for the `before == 0`
/// guard (AGENTS.md's regression: the guard must stop the request
/// before it happens, not merely tolerate the empty answer).
#[derive(Default)] #[derive(Default)]
struct ScriptedTransport { struct ScriptedTransport {
responses: Mutex<VecDeque<(u16, String)>>, responses: Mutex<VecDeque<(u16, String)>>,
@@ -352,7 +264,7 @@ mod tests {
cache_root: &std::path::Path, cache_root: &std::path::Path,
) -> TranscriptSource<ScriptedTransport> { ) -> TranscriptSource<ScriptedTransport> {
let api = ApiClient::new(transport); let api = ApiClient::new(transport);
let cache = crate::transcript_cache::TranscriptCache::new(cache_root).session("s1"); let cache = crate::client::transcript_cache::TranscriptCache::new(cache_root).session("s1");
TranscriptSource::new(api, "s1", cache) TranscriptSource::new(api, "s1", cache)
} }
@@ -371,7 +283,6 @@ mod tests {
let opening = source.fetch_opening().unwrap(); let opening = source.fetch_opening().unwrap();
assert_eq!(opening.len(), 1); assert_eq!(opening.len(), 1);
assert_eq!(opening[0].seq, 1); assert_eq!(opening[0].seq, 1);
// The fetch wrote through: reopening the same cache now has something to show.
assert!(source.cache.tail().is_some()); assert!(source.cache.tail().is_some());
} }
@@ -385,7 +296,7 @@ mod tests {
let transport2 = ScriptedTransport::default(); let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(1))); transport2.respond(200, format!("[{}]", status_line(1)));
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1"); let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache); let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().unwrap()); assert!(source2.probe().unwrap());
assert!(source2.cache.tail().is_some()); assert!(source2.cache.tail().is_some());
@@ -399,12 +310,10 @@ mod tests {
let source = source(transport, dir.path()); let source = source(transport, dir.path());
source.fetch_opening().unwrap(); source.fetch_opening().unwrap();
// The server now answers with a different event at the same seq -- the file
// behind this session was replaced.
let transport2 = ScriptedTransport::default(); let transport2 = ScriptedTransport::default();
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string(); let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
transport2.respond(200, format!("[{different}]")); transport2.respond(200, format!("[{different}]"));
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1"); let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache); let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(!source2.probe().unwrap()); assert!(!source2.probe().unwrap());
assert!(source2.cache.tail().is_none()); assert!(source2.cache.tail().is_none());
@@ -420,7 +329,7 @@ mod tests {
let transport2 = ScriptedTransport::default(); let transport2 = ScriptedTransport::default();
transport2.respond(500, "server on fire"); transport2.respond(500, "server on fire");
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1"); let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache); let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().is_err()); assert!(source2.probe().is_err());
assert!( assert!(
@@ -429,10 +338,6 @@ mod tests {
); );
} }
/// The regression this module exists to close: `before == 0` must
/// never reach the network or the cache, because an empty answer there
/// is indistinguishable from "there is genuinely no more history" --
/// AGENTS.md's `loadOlderPage` incident.
#[test] #[test]
fn paging_before_the_first_event_makes_no_request_at_all() { fn paging_before_the_first_event_makes_no_request_at_all() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -463,8 +368,6 @@ mod tests {
); );
} }
/// With nothing older cached there is no floor to give the server, so
/// the request carries no `after` at all.
#[test] #[test]
fn a_server_page_with_nothing_older_cached_carries_no_bound() { fn a_server_page_with_nothing_older_cached_carries_no_bound() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -475,7 +378,7 @@ mod tests {
let transport2 = ScriptedTransport::default(); let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(3))); transport2.respond(200, format!("[{}]", status_line(3)));
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1"); let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache); let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
source2.page(5, 10, true).unwrap(); source2.page(5, 10, true).unwrap();
assert_eq!( assert_eq!(
@@ -484,17 +387,10 @@ mod tests {
); );
} }
/// The half the test above cannot show: when the cache *does* hold an
/// older run, the fetch is floored at its end, or the page would run
/// straight past it and overlap -- which `store_page` then refuses,
/// silently costing the phone the page it just paid for.
#[test] #[test]
fn a_server_page_is_floored_at_the_end_of_the_cached_run() { fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1"); let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
// A stored page covering [3, 6) and two live events above it, so the run this
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
// cache reads the directory as damaged and discards it.
let lines: Vec<String> = (3..6).map(status_line).collect(); let lines: Vec<String> = (3..6).map(status_line).collect();
assert!(cache.store_page(&lines, 3, 6, true)); assert!(cache.store_page(&lines, 3, 6, true));
cache.append(&status_line(6), 6); cache.append(&status_line(6), 6);
@@ -512,9 +408,6 @@ mod tests {
); );
} }
/// A page the server could not answer is an error, never an empty
/// page: the caller would read the second as "this conversation has no
/// more history" and stop paging for good.
#[test] #[test]
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() { fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -524,12 +417,10 @@ mod tests {
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),)); assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
} }
/// A cached line this build cannot read is told apart from the network
/// failing, for the same reason: neither is "no more history".
#[test] #[test]
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() { fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1"); let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.store_page( cache.store_page(
&[r#"{"seq":3,"but":"not an event"}"#.to_string()], &[r#"{"seq":3,"but":"not an event"}"#.to_string()],
3, 3,
@@ -551,7 +442,7 @@ mod tests {
#[test] #[test]
fn a_bad_cached_opening_line_purges_rather_than_panicking() { fn a_bad_cached_opening_line_purges_rather_than_panicking() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1"); let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.append("not json at all", 1); cache.append("not json at all", 1);
cache.flush(); cache.flush();
let transport = ScriptedTransport::default(); let transport = ScriptedTransport::default();
@@ -1,47 +1,6 @@
//! RUST.md's E4: a session list on the left, `transcript-ui`'s screen (I5) use crate::client::api::{ApiClient, SessionSummary, UreqTransport};
//! filling the rest, both against a real `ai-server` reached through use crate::client::event_stream::{StreamItem, follow_session_events};
//! `client-core`. The layout is the simplest thing that shows both at use crate::client::transcript_fold::{
//! once -- a fixed-width column and `rest(1)` for everything else, using
//! `iris::widget::{Span, WidgetPtr}` the way `tabs-ui` already switches
//! panes, rather than anything desktop-specific:
//!
//! ```text
//! +-----------+--------------------------------------+
//! | session | transcript_ui::TranscriptScreen |
//! | list | (List of folded rows + composer) |
//! | (WidgetPtr| |
//! | swapped | (WidgetPtr swapped whole on session |
//! | on data) | switch or a new transcript event) |
//! +-----------+--------------------------------------+
//! ```
//!
//! **Incoming SSE events go through `TranscriptScreen::apply`**, not a
//! full rebuild: `client_core::transcript_fold::fold_event` folds the new
//! item list as before, then `apply` updates only the row(s) that actually
//! changed (almost always the one still-open assistant message a delta
//! landed in) instead of rebuilding the whole right-hand widget tree from
//! scratch. `rebuild_transcript` still runs the whole tree once, for a
//! freshly loaded/selected session and for `apply`'s own rare
//! full-rebuild fallback (a `group_tool_runs` regroup touching a row
//! before the tail). The composer's in-progress text survives a rebuild
//! (`rebuild_transcript`'s `in_progress` local) since the user typing a
//! followup while a reply streams in is the one case a naive rebuild
//! would otherwise lose data on -- `apply`'s own path never touches the
//! composer at all, so this only matters on the fallback.
//!
//! Background network I/O (`client_core::api`/`event_stream`, both
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
//! `std::thread`s that report back through `winit`'s `EventLoopProxy`
//! (`Proxy<AppEvent>`), rather than through iris's own `Tasks`/`task_on`:
//! `Tasks` only requests a redraw once, after its whole async closure
//! finishes, which fits a single request-then-update but not a live SSE
//! loop that needs to be seen redrawing after *each* event it relays.
//! `Proxy::send_event` wakes the window's event loop immediately, once per
//! event, which is what a stream wants.
use client_core::api::{ApiClient, SessionSummary, UreqTransport};
use client_core::event_stream::{StreamItem, follow_session_events};
use client_core::transcript_fold::{
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq, TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
}; };
use event_model::SeqEvent; use event_model::SeqEvent;
@@ -49,18 +8,8 @@ use iris::prelude::*;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
/// The session list column's width -- a fixed size for the simplest
/// layout that shows both panels at once (UI_RULES's text-truncation and
/// no-shrink rules apply to what's drawn inside it, not to this choice of
/// column width itself).
const LIST_WIDTH: f32 = 260.0; const LIST_WIDTH: f32 = 260.0;
/// Everything a background thread hands back to the window's event loop.
/// `generation` on the session-scoped variants is the generation
/// `select_session` was on when the thread started (`Client::generation`)
/// -- compared back against the current one before being applied, so a
/// slow response from a session the reader has since clicked away from
/// can't overwrite what replaced it.
enum AppEvent { enum AppEvent {
Sessions(Result<Vec<SessionSummary>, String>), Sessions(Result<Vec<SessionSummary>, String>),
TranscriptLoaded { TranscriptLoaded {
@@ -89,13 +38,6 @@ pub fn run() {
struct Client { struct Client {
ui_state: DefaultUiState, ui_state: DefaultUiState,
api: Arc<ApiClient<UreqTransport>>, api: Arc<ApiClient<UreqTransport>>,
/// A second, independent `UreqTransport` to the same server, used only
/// by `select_session`'s live-follow loop. `ApiClient` keeps its
/// transport private (rightly -- nothing outside it should reach past
/// the typed calls), so a caller that also needs the raw
/// `Transport::stream` for SSE, as this one does, builds its own
/// rather than the crate growing a getter whose only purpose would be
/// letting one caller reach around its own abstraction.
stream_transport: Arc<UreqTransport>, stream_transport: Arc<UreqTransport>,
proxy: Proxy<AppEvent>, proxy: Proxy<AppEvent>,
sessions: Vec<SessionSummary>, sessions: Vec<SessionSummary>,
@@ -103,9 +45,7 @@ struct Client {
items: Vec<TranscriptItem>, items: Vec<TranscriptItem>,
list_ptr: WeakWidget<WidgetPtr>, list_ptr: WeakWidget<WidgetPtr>,
transcript_ptr: WeakWidget<WidgetPtr>, transcript_ptr: WeakWidget<WidgetPtr>,
screen: Option<transcript_ui::TranscriptScreen>, screen: Option<crate::ui::TranscriptScreen>,
/// Bumped every time the selected session changes; see `AppEvent`'s
/// doc for what it guards against.
generation: Arc<AtomicU64>, generation: Arc<AtomicU64>,
} }
@@ -117,13 +57,7 @@ impl DefaultAppState for Client {
rsc: &mut DefaultRsc<Self>, rsc: &mut DefaultRsc<Self>,
proxy: Proxy<AppEvent>, proxy: Proxy<AppEvent>,
) -> Self { ) -> Self {
// Re-validated here rather than threaded through from `main` -- let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| {
// `DefaultApp::run()` takes no payload, so there is no other way
// to get `main`'s parsed CLI/config into this constructor. `main`
// already called this once to fail fast before a window opens;
// this call only fails if the filesystem changed underneath the
// process in between, which is not a case worth a nicer message.
let (server, ca_pem) = crate::load_startup_config().unwrap_or_else(|e| {
eprintln!("desktop-app: {e}"); eprintln!("desktop-app: {e}");
std::process::exit(2); std::process::exit(2);
}); });
@@ -237,9 +171,6 @@ impl Client {
&& self.generation.load(Ordering::SeqCst) == generation && self.generation.load(Ordering::SeqCst) == generation
} }
/// Replaces the right-hand panel with a line of text -- built before
/// `transcript_ptr` is reached for, since building the message and
/// swapping it in both need `rsc` and can't overlap as one borrow.
fn show_message(&mut self, rsc: &mut DefaultRsc<Self>, message: &str) { fn show_message(&mut self, rsc: &mut DefaultRsc<Self>, message: &str) {
let widget = placeholder(rsc, message); let widget = placeholder(rsc, message);
(self.transcript_ptr)(rsc).set(widget); (self.transcript_ptr)(rsc).set(widget);
@@ -262,17 +193,12 @@ impl Client {
list(rsc).push(row); list(rsc).push(row);
} }
let tree = list let tree = list
.background(rect(Color::rgb(24, 24, 28))) .background(rect(Srgba8::rgb(24, 24, 28)))
.add_strong(rsc) .add_strong(rsc)
.any(); .any();
(self.list_ptr)(rsc).set(tree); (self.list_ptr)(rsc).set(tree);
} }
/// Selecting a session starts a fresh generation: any thread still
/// working for the previous one checks `Client::current` before
/// touching state, so a slow response for a session the reader has
/// clicked away from is silently dropped rather than overwriting what
/// replaced it.
fn select_session(&mut self, rsc: &mut DefaultRsc<Self>, session_id: String) { fn select_session(&mut self, rsc: &mut DefaultRsc<Self>, session_id: String) {
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
self.selected = Some(session_id.clone()); self.selected = Some(session_id.clone());
@@ -286,24 +212,9 @@ impl Client {
let proxy = self.proxy.clone(); let proxy = self.proxy.clone();
let live_generation = self.generation.clone(); let live_generation = self.generation.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
// The most recent 200 events, coalesced -- plenty for a
// desktop proof; RUST.md's I3/history-paging work is what a
// real scrollback would reuse, out of scope here (E4 is only
// "the same screen runs in a window").
let page: Result<Vec<serde_json::Value>, String> = api let page: Result<Vec<serde_json::Value>, String> = api
.fetch_transcript_page(&session_id, None, 200, true) .fetch_transcript_page(&session_id, None, 200, true)
.map_err(|e| e.to_string()); .map_err(|e| e.to_string());
// The raw wire `seq` of the last line fetched -- not the seq of
// the last *folded item*. A `TranscriptItem::AssistantMsg` keeps
// the seq of the first delta it accumulated (`fold_event`'s own
// doc: "a row whose identity changed with every delta would be
// a new row every frame"), so resuming the live stream from
// that seq re-delivers every delta already folded into it,
// duplicating the tail of whatever reply was mid-stream when
// the page was fetched. Found by screenshotting a real reply
// through `run-headless.sh`: the assistant's line read "You
// said: ... testsaid: ... test", the back half being deltas 2
// through N replayed onto an already-complete message.
let after = page let after = page
.as_ref() .as_ref()
.ok() .ok()
@@ -316,9 +227,6 @@ impl Client {
result, result,
}); });
// Follows live from here in the same thread -- sequential
// rather than a second thread, since there is nothing to do
// with the stream until the page above has been sent anyway.
let stop = || live_generation.load(Ordering::SeqCst) != generation; let stop = || live_generation.load(Ordering::SeqCst) != generation;
if stop() { if stop() {
return; return;
@@ -364,7 +272,7 @@ impl Client {
.filter(|t| !t.is_empty()); .filter(|t| !t.is_empty());
let rows = group_tool_runs(&self.items); let rows = group_tool_runs(&self.items);
let (screen, tree) = transcript_ui::build_tree(rsc, rows); let (screen, tree) = crate::ui::build_tree(rsc, rows);
if let Some(text) = in_progress { if let Some(text) = in_progress {
screen.composer.field.edit(rsc).set(&text); screen.composer.field.edit(rsc).set(&text);
@@ -385,22 +293,20 @@ impl Client {
} }
} }
/// One row in the session list: title on top, status below, highlighted
/// when it's the one currently shown.
fn session_row( fn session_row(
rsc: &mut DefaultRsc<Client>, rsc: &mut DefaultRsc<Client>,
session: &SessionSummary, session: &SessionSummary,
selected: bool, selected: bool,
) -> StrongWidget { ) -> StrongWidget {
let bg = if selected { let bg = if selected {
Color::rgb(58, 90, 138) Srgba8::rgb(58, 90, 138)
} else { } else {
Color::rgb(38, 38, 44) Srgba8::rgb(38, 38, 44)
}; };
let id = session.id.clone(); let id = session.id.clone();
let label = format!("{}\n{}", session.title, session.status); let label = format!("{}\n{}", session.title, session.status);
wtext(label) wtext(label)
.color(Color::WHITE) .color(PaintId::WHITE)
.wrap(true) .wrap(true)
.pad(10) .pad(10)
.width(rest(1)) .width(rest(1))
@@ -417,7 +323,7 @@ fn session_row(
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget { fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
wtext(message.to_string()) wtext(message.to_string())
.color(Color::WHITE) .color(PaintId::WHITE)
.wrap(true) .wrap(true)
.pad(16) .pad(16)
.add_strong(rsc) .add_strong(rsc)
+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 jcall;
mod notify; mod notify;
mod settings; mod settings;
@@ -28,15 +8,6 @@ use jni::objects::{JClass, JObject};
use jni::sys::jint; use jni::sys::jint;
use jni::{Env, NativeMethod, native_method}; use jni::{Env, NativeMethod, native_method};
/// Installs the `log` backend that routes to logcat, once per process.
/// Without it, `LogErrorAndDefault` (every native method below) and any
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
/// call into the `log` facade's default no-op logger, and a real failure
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
/// crashing, since nothing on screen or in the log says a notification was
/// dropped. Called from every entry point below rather than a Java-side
/// `Application.onCreate`, since this crate deliberately has no such class
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
fn ensure_logger() { fn ensure_logger() {
static ONCE: std::sync::Once = std::sync::Once::new(); static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| { ONCE.call_once(|| {
@@ -65,8 +36,6 @@ const _: NativeMethod = native_method! {
error_policy = LogErrorAndDefault, error_policy = LogErrorAndDefault,
}; };
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
fn native_handle_intent<'local>( fn native_handle_intent<'local>(
env: &mut Env<'local>, env: &mut Env<'local>,
_class: JClass<'local>, _class: JClass<'local>,
@@ -84,9 +53,6 @@ const _: NativeMethod = native_method! {
error_policy = LogErrorAndDefault, error_policy = LogErrorAndDefault,
}; };
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
/// enrollment may have just landed) and from `NotificationService.sync`
/// itself. See `notify::sync`.
fn native_sync<'local>( fn native_sync<'local>(
env: &mut Env<'local>, env: &mut Env<'local>,
_class: JClass<'local>, _class: JClass<'local>,
@@ -103,7 +69,6 @@ const _: NativeMethod = native_method! {
error_policy = LogErrorAndDefault, error_policy = LogErrorAndDefault,
}; };
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
fn native_on_start_command<'local>( fn native_on_start_command<'local>(
env: &mut Env<'local>, env: &mut Env<'local>,
_class: JClass<'local>, _class: JClass<'local>,
@@ -120,7 +85,6 @@ const _: NativeMethod = native_method! {
error_policy = LogErrorAndDefault, error_policy = LogErrorAndDefault,
}; };
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
fn native_on_destroy<'local>( fn native_on_destroy<'local>(
_env: &mut Env<'local>, _env: &mut Env<'local>,
_class: JClass<'local>, _class: JClass<'local>,
@@ -12,20 +12,19 @@
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use client_core::api::UreqTransport; use crate::client::api::UreqTransport;
use client_core::notifications::{SessionNotification, follow_notifications}; use crate::client::notifications::{SessionNotification, follow_notifications};
use jni::Env; use jni::Env;
use jni::errors::Result; use jni::errors::Result;
use jni::objects::{JObject, JValue}; use jni::objects::{JObject, JValue};
use jni::sys::{JNI_TRUE, jint}; use jni::sys::{JNI_TRUE, jint};
use crate::settings::{self, ServerSettings}; use crate::shell::settings::{self, ServerSettings};
const ALERT_CHANNEL: &str = "sessions"; const ALERT_CHANNEL: &str = "sessions";
const ONGOING_CHANNEL: &str = "connection"; const ONGOING_CHANNEL: &str = "connection";
const ONGOING_ID: i32 = 1; const ONGOING_ID: i32 = 1;
const ALERT_ID: i32 = 2; const ALERT_ID: i32 = 2;
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000); const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
/// Whether the follow-loop thread is already running. **A deviation from /// Whether the follow-loop thread is already running. **A deviation from
@@ -45,25 +44,14 @@ const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
/// same guard back to `Notifications.kt` separately. /// same guard back to `Notifications.kt` separately.
static RUNNING: AtomicBool = AtomicBool::new(false); static RUNNING: AtomicBool = AtomicBool::new(false);
/// Set by `nativeOnDestroy`, checked by the follow loop between
/// reconnects. **Known gap, recorded rather than hidden**: unlike
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
/// can interrupt a `ureq` read already blocked inside one connection --
/// `Transport::stream` hands back a plain `Read` with no cancellation
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
/// practice this is a bounded wait rather than a hang; closing that gap
/// for real means adding a cancellation point to `client_core::Transport`,
/// which is a decision affecting every caller of that trait, not just this
/// one -- left for whoever next depends on prompt shutdown.
static STOPPING: AtomicBool = AtomicBool::new(false); static STOPPING: AtomicBool = AtomicBool::new(false);
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> { fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
crate::jcall::get_static_field(env, class, field, "I")?.i() crate::shell::jcall::get_static_field(env, class, field, "I")?.i()
} }
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> { fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
crate::jcall::call_static_method( crate::shell::jcall::call_static_method(
env, env,
"androidx/core/app/NotificationManagerCompat", "androidx/core/app/NotificationManagerCompat",
"from", "from",
@@ -80,22 +68,22 @@ fn create_channel(
name: &str, name: &str,
importance: i32, importance: i32,
) -> Result<()> { ) -> Result<()> {
let id_j = crate::jcall::jstr_obj(env, id)?; let id_j = crate::shell::jcall::jstr_obj(env, id)?;
let builder = crate::jcall::new_object( let builder = crate::shell::jcall::new_object(
env, env,
"androidx/core/app/NotificationChannelCompat$Builder", "androidx/core/app/NotificationChannelCompat$Builder",
"(Ljava/lang/String;I)V", "(Ljava/lang/String;I)V",
&[JValue::Object(&id_j), JValue::Int(importance)], &[JValue::Object(&id_j), JValue::Int(importance)],
)?; )?;
let name_j = crate::jcall::jstr_obj(env, name)?; let name_j = crate::shell::jcall::jstr_obj(env, name)?;
crate::jcall::call_method( crate::shell::jcall::call_method(
env, env,
&builder, &builder,
"setName", "setName",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;", "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
&[JValue::Object(&name_j)], &[JValue::Object(&name_j)],
)?; )?;
let channel = crate::jcall::call_method( let channel = crate::shell::jcall::call_method(
env, env,
&builder, &builder,
"build", "build",
@@ -103,7 +91,7 @@ fn create_channel(
&[], &[],
)? )?
.l()?; .l()?;
crate::jcall::call_method( crate::shell::jcall::call_method(
env, env,
manager, manager,
"createNotificationChannel", "createNotificationChannel",
@@ -146,8 +134,8 @@ fn new_intent_for<'l>(
context: &JObject, context: &JObject,
class_name: &str, class_name: &str,
) -> Result<JObject<'l>> { ) -> Result<JObject<'l>> {
let target_class = crate::jcall::find_class(env, class_name)?; let target_class = crate::shell::jcall::find_class(env, class_name)?;
crate::jcall::new_object( crate::shell::jcall::new_object(
env, env,
"android/content/Intent", "android/content/Intent",
"(Landroid/content/Context;Ljava/lang/Class;)V", "(Landroid/content/Context;Ljava/lang/Class;)V",
@@ -165,41 +153,42 @@ fn session_intent<'l>(
session_id: &str, session_id: &str,
) -> Result<JObject<'l>> { ) -> Result<JObject<'l>> {
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?; let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?; let action_view = crate::shell::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
crate::jcall::call_method( crate::shell::jcall::call_method(
env, env,
&intent, &intent,
"setAction", "setAction",
"(Ljava/lang/String;)Landroid/content/Intent;", "(Ljava/lang/String;)Landroid/content/Intent;",
&[JValue::Object(&action_view)], &[JValue::Object(&action_view)],
)?; )?;
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?; let builder = crate::shell::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?; let scheme = crate::shell::jcall::jstr_obj(env, settings::SCHEME)?;
crate::jcall::call_method( crate::shell::jcall::call_method(
env, env,
&builder, &builder,
"scheme", "scheme",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;", "(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&scheme)], &[JValue::Object(&scheme)],
)?; )?;
let authority = crate::jcall::jstr_obj(env, "session")?; let authority = crate::shell::jcall::jstr_obj(env, "session")?;
crate::jcall::call_method( crate::shell::jcall::call_method(
env, env,
&builder, &builder,
"authority", "authority",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;", "(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&authority)], &[JValue::Object(&authority)],
)?; )?;
let path = crate::jcall::jstr_obj(env, session_id)?; let path = crate::shell::jcall::jstr_obj(env, session_id)?;
crate::jcall::call_method( crate::shell::jcall::call_method(
env, env,
&builder, &builder,
"appendPath", "appendPath",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;", "(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&path)], &[JValue::Object(&path)],
)?; )?;
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?; let uri = crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?
crate::jcall::call_method( .l()?;
crate::shell::jcall::call_method(
env, env,
&intent, &intent,
"setData", "setData",
@@ -216,7 +205,7 @@ fn pending_activity<'l>(
) -> Result<JObject<'l>> { ) -> Result<JObject<'l>> {
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?; let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?; let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
crate::jcall::call_static_method( crate::shell::jcall::call_static_method(
env, env,
"android/app/PendingIntent", "android/app/PendingIntent",
"getActivity", "getActivity",
@@ -238,7 +227,7 @@ fn builder_call<'l>(
sig: &str, sig: &str,
args: &[JValue], args: &[JValue],
) -> Result<()> { ) -> Result<()> {
crate::jcall::call_method(env, builder, method, sig, args)?; crate::shell::jcall::call_method(env, builder, method, sig, args)?;
Ok(()) Ok(())
} }
@@ -259,14 +248,14 @@ fn foreground_type(env: &mut Env) -> Result<i32> {
} }
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> { fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?; let channel = crate::shell::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
let builder = crate::jcall::new_object( let builder = crate::shell::jcall::new_object(
env, env,
"androidx/core/app/NotificationCompat$Builder", "androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V", "(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)], &[JValue::Object(context), JValue::Object(&channel)],
)?; )?;
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?; let title = crate::shell::jcall::jstr_obj(env, "Watching for sessions that need you")?;
builder_call( builder_call(
env, env,
&builder, &builder,
@@ -297,7 +286,8 @@ fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObj
"(I)Landroidx/core/app/NotificationCompat$Builder;", "(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(priority_min)], &[JValue::Int(priority_min)],
)?; )?;
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l() crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
.l()
} }
/// Starts the service if there is a server to connect to, and stops it /// Starts the service if there is a server to connect to, and stops it
@@ -306,7 +296,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
let service_intent = let service_intent =
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?; new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
if settings::load(env, context)?.is_none() { if settings::load(env, context)?.is_none() {
crate::jcall::call_method( crate::shell::jcall::call_method(
env, env,
context, context,
"stopService", "stopService",
@@ -316,7 +306,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
return Ok(()); return Ok(());
} }
create_channels(env, context)?; create_channels(env, context)?;
crate::jcall::call_static_method( crate::shell::jcall::call_static_method(
env, env,
"androidx/core/content/ContextCompat", "androidx/core/content/ContextCompat",
"startForegroundService", "startForegroundService",
@@ -326,16 +316,11 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
Ok(()) Ok(())
} }
/// The `Service.onStartCommand` body -- loads settings, starts the
/// foreground notification, and spawns the follow-loop thread. Answers the
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
/// framework rather than hardcoded so a wrong guess at their values cannot
/// silently pick the other behaviour.
pub fn on_start_command(env: &mut Env, service: JObject) -> jint { pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
match try_start(env, &service) { match try_start(env, &service) {
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1), Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
Ok(false) => { Ok(false) => {
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]); let _ = crate::shell::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2) static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
} }
Err(e) => { Err(e) => {
@@ -352,7 +337,7 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
let ca = settings::load_pinned_ca(env)?; let ca = settings::load_pinned_ca(env)?;
let notification = ongoing_notification(env, service)?; let notification = ongoing_notification(env, service)?;
let fg_type = foreground_type(env)?; let fg_type = foreground_type(env)?;
crate::jcall::call_static_method( crate::shell::jcall::call_static_method(
env, env,
"androidx/core/app/ServiceCompat", "androidx/core/app/ServiceCompat",
"startForeground", "startForeground",
@@ -379,9 +364,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
std::thread::Builder::new() std::thread::Builder::new()
.name("ai-app-notifications".to_string()) .name("ai-app-notifications".to_string())
.spawn(move || { .spawn(move || {
// Requests a *permanent* attachment (detached only when this thread
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
// this is the long-lived follow loop, not a one-shot callback.
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| { let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
follow_loop(env, &context, settings, &ca); follow_loop(env, &context, settings, &ca);
Ok(()) Ok(())
@@ -391,12 +373,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
Ok(true) Ok(true)
} }
/// Follows the backend's notification stream, reconnecting until stopped
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
/// ordinary case, so it retries quietly and forever; nothing is shown when
/// it cannot connect, for the same reason as the Kotlin original: a
/// notification saying "I could not tell you whether anything happened" is
/// noise about a condition nobody can act on.
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) { fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
while !STOPPING.load(Ordering::SeqCst) { while !STOPPING.load(Ordering::SeqCst) {
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) { if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
@@ -414,9 +390,6 @@ fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &
} }
} }
/// One notification per session, replacing that session's previous one --
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
/// branches this module's doc comment explains.
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> { fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
let manager = notification_manager(env, context)?; let manager = notification_manager(env, context)?;
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?; let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
@@ -424,13 +397,14 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
let allowed = if sdk < tiramisu { let allowed = if sdk < tiramisu {
true true
} else { } else {
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?; let permission =
crate::shell::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
let granted = static_int( let granted = static_int(
env, env,
"android/content/pm/PackageManager", "android/content/pm/PackageManager",
"PERMISSION_GRANTED", "PERMISSION_GRANTED",
)?; )?;
let result = crate::jcall::call_static_method( let result = crate::shell::jcall::call_static_method(
env, env,
"androidx/core/content/ContextCompat", "androidx/core/content/ContextCompat",
"checkSelfPermission", "checkSelfPermission",
@@ -441,20 +415,21 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
result == granted result == granted
}; };
let enabled = let enabled =
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?; crate::shell::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?
.z()?;
if !allowed || !enabled { if !allowed || !enabled {
return Ok(()); return Ok(());
} }
let intent = session_intent(env, context, &notification.session_id)?; let intent = session_intent(env, context, &notification.session_id)?;
let pending = pending_activity(env, context, &intent)?; let pending = pending_activity(env, context, &intent)?;
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?; let channel = crate::shell::jcall::jstr_obj(env, ALERT_CHANNEL)?;
let builder = crate::jcall::new_object( let builder = crate::shell::jcall::new_object(
env, env,
"androidx/core/app/NotificationCompat$Builder", "androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V", "(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)], &[JValue::Object(context), JValue::Object(&channel)],
)?; )?;
let title = crate::jcall::jstr_obj(env, &notification.title)?; let title = crate::shell::jcall::jstr_obj(env, &notification.title)?;
builder_call( builder_call(
env, env,
&builder, &builder,
@@ -462,7 +437,7 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;", "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)], &[JValue::Object(&title)],
)?; )?;
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?; let text = crate::shell::jcall::jstr_obj(env, notification.kind.attention_line())?;
builder_call( builder_call(
env, env,
&builder, &builder,
@@ -507,11 +482,16 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
"(Z)Landroidx/core/app/NotificationCompat$Builder;", "(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)], &[JValue::Bool(JNI_TRUE)],
)?; )?;
let built = let built = crate::shell::jcall::call_method(
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])? env,
&builder,
"build",
"()Landroid/app/Notification;",
&[],
)?
.l()?; .l()?;
let tag = crate::jcall::jstr_obj(env, &notification.session_id)?; let tag = crate::shell::jcall::jstr_obj(env, &notification.session_id)?;
crate::jcall::call_method( crate::shell::jcall::call_method(
env, env,
&manager, &manager,
"notify", "notify",
@@ -525,8 +505,6 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
Ok(()) Ok(())
} }
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
/// the gap this module's `STOPPING` doc explains.
pub fn on_destroy() { pub fn on_destroy() {
STOPPING.store(true, Ordering::SeqCst); STOPPING.store(true, Ordering::SeqCst);
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own // `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
@@ -542,9 +520,9 @@ pub fn on_destroy() {
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) { pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
let message = format!("android-shell: {where_}: {error}"); let message = format!("android-shell: {where_}: {error}");
let _ = (|| -> Result<()> { let _ = (|| -> Result<()> {
let tag = crate::jcall::jstr_obj(env, "android-shell")?; let tag = crate::shell::jcall::jstr_obj(env, "android-shell")?;
let msg = crate::jcall::jstr_obj(env, &message)?; let msg = crate::shell::jcall::jstr_obj(env, &message)?;
crate::jcall::call_static_method( crate::shell::jcall::call_static_method(
env, env,
"android/util/Log", "android/util/Log",
"e", "e",
@@ -28,20 +28,15 @@ impl ServerSettings {
} }
} }
/// This experiment's own scheme and Keystore alias -- distinct from the
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
/// installed side by side on the same development device without
/// colliding over which one a scanned QR or a deep link resolves to. See
/// RUST.md's E3 entry for why they are not the same value.
pub(crate) const SCHEME: &str = "aiappshell"; pub(crate) const SCHEME: &str = "aiappshell";
const KEY_ALIAS: &str = "aiapp-shell-token-key"; const KEY_ALIAS: &str = "aiapp-shell-token-key";
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore"; const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings"; const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> { fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
let scheme = crate::jcall::jstr_obj(env, SCHEME)?; let scheme = crate::shell::jcall::jstr_obj(env, SCHEME)?;
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?; let alias = crate::shell::jcall::jstr_obj(env, KEY_ALIAS)?;
crate::jcall::new_object( crate::shell::jcall::new_object(
env, env,
STORE_CLASS, STORE_CLASS,
"(Ljava/lang/String;Ljava/lang/String;)V", "(Ljava/lang/String;Ljava/lang/String;)V",
@@ -51,13 +46,14 @@ fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> { fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
let host = get_string(env, settings_obj, "getHost")?; let host = get_string(env, settings_obj, "getHost")?;
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?; let port = crate::shell::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
let token = get_string(env, settings_obj, "getToken")?; let token = get_string(env, settings_obj, "getToken")?;
Ok(ServerSettings { host, port, token }) Ok(ServerSettings { host, port, token })
} }
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> { fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?; let value =
crate::shell::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
let jstr: JString = env.cast_local::<JString>(value)?; let jstr: JString = env.cast_local::<JString>(value)?;
jstr.try_to_string(env) jstr.try_to_string(env)
} }
@@ -66,7 +62,7 @@ fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
/// `ServerConfig.kt`'s `loadServerSettings`. /// `ServerConfig.kt`'s `loadServerSettings`.
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> { pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?; let store = new_store(env)?;
let settings_obj = crate::jcall::call_method( let settings_obj = crate::shell::jcall::call_method(
env, env,
&store, &store,
"load", "load",
@@ -80,12 +76,11 @@ pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>>
Ok(Some(read_settings(env, &settings_obj)?)) Ok(Some(read_settings(env, &settings_obj)?))
} }
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> { pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
let store = new_store(env)?; let store = new_store(env)?;
let host = crate::jcall::jstr_obj(env, &settings.host)?; let host = crate::shell::jcall::jstr_obj(env, &settings.host)?;
let token = crate::jcall::jstr_obj(env, &settings.token)?; let token = crate::shell::jcall::jstr_obj(env, &settings.token)?;
let settings_obj = crate::jcall::new_object( let settings_obj = crate::shell::jcall::new_object(
env, env,
SETTINGS_CLASS, SETTINGS_CLASS,
"(Ljava/lang/String;ILjava/lang/String;)V", "(Ljava/lang/String;ILjava/lang/String;)V",
@@ -95,7 +90,7 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
JValue::Object(&token), JValue::Object(&token),
], ],
)?; )?;
crate::jcall::call_method( crate::shell::jcall::call_method(
env, env,
&store, &store,
"save", "save",
@@ -105,12 +100,9 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
Ok(()) Ok(())
} }
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
/// query-parameter rules rather than re-deriving them here.
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> { pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?; let store = new_store(env)?;
let settings_obj = crate::jcall::call_method( let settings_obj = crate::shell::jcall::call_method(
env, env,
&store, &store,
"parseEnrollmentUri", "parseEnrollmentUri",
@@ -129,7 +121,7 @@ pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<Serve
/// but into a plain Java constant, since this module has no Kotlin of its /// but into a plain Java constant, since this module has no Kotlin of its
/// own to generate into. /// own to generate into.
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> { pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
let value = crate::jcall::get_static_field( let value = crate::shell::jcall::get_static_field(
env, env,
"com/example/aiapp/shell/PinnedCa", "com/example/aiapp/shell/PinnedCa",
"PINNED_CA_PEM", "PINNED_CA_PEM",
@@ -1,30 +1,10 @@
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s use crate::client::api::{ApiClient, UreqTransport};
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
//!
//! **Scope cut, recorded rather than silent**: only shared *text*
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
//! ported here, because `client-core`'s `ApiClient` does not have the
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
//! `content://` stream and no text falls through to a toast saying so,
//! rather than silently doing nothing. Closing this gap is the same
//! `client-core` work whichever caller needs it next.
//!
//! **Which session a share lands in** is also a placeholder: with no
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
//! to whichever session has the latest `last_activity` -- the one most
//! likely to be what somebody meant. Worth revisiting once a real screen
//! exists to ask instead of guessing.
use client_core::api::{ApiClient, UreqTransport};
use jni::Env; use jni::Env;
use jni::errors::Result; use jni::errors::Result;
use jni::objects::{JObject, JString, JValue}; use jni::objects::{JObject, JString, JValue};
use crate::notify; use crate::shell::notify;
use crate::settings; use crate::shell::settings;
const ACTION_SEND: &str = "android.intent.action.SEND"; const ACTION_SEND: &str = "android.intent.action.SEND";
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE"; const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
@@ -32,7 +12,8 @@ const ACTION_VIEW: &str = "android.intent.action.VIEW";
const EXTRA_TEXT: &str = "android.intent.extra.TEXT"; const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> { fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?; let value =
crate::shell::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
if value.is_null() { if value.is_null() {
return Ok(None); return Ok(None);
} }
@@ -41,8 +22,8 @@ fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Optio
} }
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> { fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
let message = crate::jcall::jstr_obj(env, message)?; let message = crate::shell::jcall::jstr_obj(env, message)?;
crate::jcall::call_static_method( crate::shell::jcall::call_static_method(
env, env,
"com/example/aiapp/shell/MainActivity", "com/example/aiapp/shell/MainActivity",
"toast", "toast",
@@ -52,8 +33,6 @@ fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// The one place an incoming intent is sorted into what it means -- mirrors
/// `MainActivity.kt`'s `handleIntent`.
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> { pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let action = get_string_method(env, intent, "getAction")?; let action = get_string_method(env, intent, "getAction")?;
if matches!( if matches!(
@@ -65,7 +44,8 @@ pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Res
if action.as_deref() != Some(ACTION_VIEW) { if action.as_deref() != Some(ACTION_VIEW) {
return Ok(()); return Ok(());
} }
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?; let uri = crate::shell::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?
.l()?;
if uri.is_null() { if uri.is_null() {
return Ok(()); return Ok(());
} }
@@ -84,9 +64,6 @@ fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Resu
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else { let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
return Ok(()); return Ok(());
}; };
// There is no session screen yet (E4's job); the toast is this
// experiment's stand-in proof that the tap was routed to the right
// session id.
toast(env, activity, &format!("Opened session {session_id}")) toast(env, activity, &format!("Opened session {session_id}"))
} }
@@ -105,12 +82,9 @@ fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result
} }
} }
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
/// what happens to it, minus attachments per this module's doc comment.
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> { fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?; let extra_text = crate::shell::jcall::jstr_obj(env, EXTRA_TEXT)?;
let text = crate::jcall::call_method( let text = crate::shell::jcall::call_method(
env, env,
intent, intent,
"getStringExtra", "getStringExtra",
+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)
}
@@ -1,54 +1,20 @@
//! The checked-in bench fixture, opened as a real transcript screen with use crate::client::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
//! no server -- shared by every layer of docs/RUST.md's test rig.
//!
//! The bytes are `app/bench-fixture/assets/transcript.jsonl` (1,915,760
//! bytes, generated by `app/bench-fixture/generate.py`, never a real
//! transcript -- that file's own README), embedded with `include_str!`.
//! The first [`BACKLOG_COUNT`] non-blank lines are the opening window,
//! folded once through `client_core::transcript_fold::fold_page` exactly
//! as a real `/transcript` page would be; the rest are the streaming
//! tail, replayed one at a time through `fold_event` the way a live SSE
//! frame arrives.
//!
//! This half used to live in `iris-android-app`'s `bench_client.rs`, and
//! moved here on 2026-09-07 so the headless harness and a desktop window
//! open the same screen from the same bytes (AGENTS.md: nothing
//! UI-shaped in a platform crate). What stayed there is the JNI half --
//! the clipboard, the battery sampler, the IME calls and the report.
use client_core::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
use event_model::SeqEvent; use event_model::SeqEvent;
use iris::prelude::*; use iris::prelude::*;
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are pub const BACKLOG_COUNT: usize = 3202;
/// the opening window; the rest are the streaming tail. Kept in sync with
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
/// checked-in file, so a mismatch would only mean the two apps' bench
/// builds open a different split of it, not a wrong-vs-right answer.
pub const BACKLOG_COUNT: usize = 3200;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl"); const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
/// Iris's phone as `docs/bench/iris-phone-v2-2026-09-06.md` and
/// `docs/IRIS_TODO.md` record it: a 1080x2424 surface at
/// `content_scale: 2.55`, 120Hz. Read from those reports, never typed
/// from memory -- every layer of the rig lays out at this size and
/// density so a screenshot and a headless assertion are about the same
/// screen.
pub const PHONE_WIDTH: f32 = 1080.0; pub const PHONE_WIDTH: f32 = 1080.0;
pub const PHONE_HEIGHT: f32 = 2424.0; pub const PHONE_HEIGHT: f32 = 2424.0;
pub const PHONE_SCALE: f32 = 2.55; pub const PHONE_SCALE: f32 = 2.55;
/// 120Hz, the refresh rate that report ran at: 8.3ms a frame.
pub const PHONE_FRAME_MS: u64 = 8; pub const PHONE_FRAME_MS: u64 = 8;
pub fn phone_size() -> Vec2 { pub fn phone_size() -> Vec2 {
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT) Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
} }
/// The fixture split the way the wire delivers it: raw JSON values for
/// the opening page (`fold_page` takes a page of wire JSON, same as a
/// real `/transcript` response) and parsed `SeqEvent`s for the tail
/// (`fold_event` takes one live event at a time, same as an SSE frame).
pub struct Fixture { pub struct Fixture {
pub backlog: Vec<serde_json::Value>, pub backlog: Vec<serde_json::Value>,
pub stream_tail: Vec<SeqEvent>, pub stream_tail: Vec<SeqEvent>,
@@ -95,7 +61,6 @@ impl Fixture {
} }
} }
/// The fixture's opening page as the rows a screen is built from.
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> { pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
group_tool_runs(items) group_tool_runs(items)
} }
@@ -105,7 +70,7 @@ pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
/// streamed. The tree itself comes back separately from /// streamed. The tree itself comes back separately from
/// [`build_screen`], since whoever takes it owns it. /// [`build_screen`], since whoever takes it owns it.
pub struct Opened { pub struct Opened {
pub screen: transcript_ui::TranscriptScreen, pub screen: crate::ui::TranscriptScreen,
pub items: Vec<TranscriptItem>, pub items: Vec<TranscriptItem>,
/// The tail, for a caller that goes on replaying it one event at a /// The tail, for a caller that goes on replaying it one event at a
/// time through `fold_event`/`TranscriptScreen::apply` -- the /// time through `fold_event`/`TranscriptScreen::apply` -- the
@@ -114,7 +79,7 @@ pub struct Opened {
} }
/// Build the transcript screen over the fixture's opening page, without /// Build the transcript screen over the fixture's opening page, without
/// claiming the window's root -- `transcript_ui::build_tree`'s own split, /// 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 /// for a caller (the Android bench) that puts the screen inside a shell
/// of its own. /// of its own.
pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String> pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String>
@@ -123,7 +88,7 @@ where
{ {
let fixture = Fixture::parse(); let fixture = Fixture::parse();
let items = fixture.backlog_items()?; let items = fixture.backlog_items()?;
let (screen, tree) = transcript_ui::build_tree(rsc, rows(&items)); let (screen, tree) = crate::ui::build_tree(rsc, rows(&items));
Ok(( Ok((
Opened { Opened {
screen, screen,
@@ -134,14 +99,15 @@ where
)) ))
} }
/// [`build_screen`] with the screen as the window's root -- what the pub fn open<Rsc: HasEvents>(
/// headless harness and the desktop window open. rsc: &mut Rsc,
pub fn open<Rsc: HasEvents>(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -> Result<Opened, String> ui_state: &mut impl HasRoot<Rsc>,
) -> Result<Opened, String>
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
{ {
let (opened, tree) = build_screen(rsc)?; let (opened, tree) = build_screen(rsc)?;
ui_state.set_root(tree); ui_state.set_root(rsc, tree);
Ok(opened) Ok(opened)
} }
@@ -149,9 +115,6 @@ where
mod tests { mod tests {
use super::*; use super::*;
/// The split is what both bench clients assume; a fixture that
/// stopped having a streaming tail would make the Android bench's
/// stream phase silently measure nothing.
#[test] #[test]
fn the_fixture_has_a_backlog_and_a_streaming_tail() { fn the_fixture_has_a_backlog_and_a_streaming_tail() {
let fixture = Fixture::parse(); let fixture = Fixture::parse();
@@ -1,131 +1,36 @@
//! One markdown **block** (`client_core::markdown_blocks::Block`) rendered use crate::client::highlight::{self, Kind, Language};
//! for display: the plain text to draw, the [`SpanStyle`]s that style it, use crate::client::markdown_blocks::{Block, BlockKind};
//! the links inside it, and the [`BlockFrame`] the row builder puts around use crate::ui::theme::Theme;
//! it.
//!
//! This is the crate's answer to RUST.md's E2 finding against Masonry
//! ("rich inline text -- block-level yes, inline no, and both for the same
//! reason": `TextArea`'s `StyleSet` is one style for the whole editor,
//! `masonry/src/widgets/text_area.rs:43-44`'s `// TODO: RichTextInput`
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`) is
//! per-range, so bold/italic/inline-code/links inside one wrapped
//! paragraph render in their own style *and* the paragraph still wraps and
//! selects as one buffer.
//!
//! **Three widget shapes, not one per markdown feature** ([`BlockFrame`]).
//! A heading, a paragraph and a list are all *text with spans*; a fence
//! and a table are *verbatim text on a dark surface that pans sideways*;
//! a quote is *text behind a coloured bar*. Everything else markdown can
//! say is expressed in the spans, which cost no widgets and no layout
//! nodes. `app/.../Markdown.kt`'s component table is the reference for the
//! sizes and colours; docs/DECISIONS.md's 2026-09-06 entry records where
//! this deliberately differs.
//!
//! **What this deliberately does not attempt**, each for a reason recorded
//! here rather than silently dropped (see IRIS_TODO.md's dated entries for
//! the same list):
//! - **No background chip behind inline code.** Drawing one needs the
//! glyph run's own geometry (the way `TextEdit::draw`'s selection
//! highlight uses `selection.geometry(layout)`,
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal.
//! `SpanStyle` gives the code range a monospace family and the
//! palette's code colour instead -- visually distinct, just not
//! chip-shaped.
//! - **A list's indent is written in spaces**, not measured. Compose lays
//! an item out as a marker column beside a text column, which keeps a
//! wrapped second line aligned under the first; here the marker is part
//! of the same buffer, so a wrapped line returns to the left margin.
//! Doing better needs per-line indent in `TextAttrs`, which nothing else
//! wants yet.
//!
//! A heading's `SpanStyle::font_size` override does not also raise its
//! `line_height` (a buffer has one, set from the *base* font size in
//! `TextAttrs`), so a heading's own line looks slightly tighter than a
//! paragraph's -- visible, not incorrect, and not fixed here since it
//! needs `SpanStyle` to carry line-height too.
use client_core::highlight::{self, Kind, Language};
use client_core::markdown_blocks::{Block, BlockKind};
use iris::prelude::*; use iris::prelude::*;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::ops::Range; use std::ops::Range;
// `UiColor` is `Color<u8>` (`core/src/lib.rs`), not the 0..1 float triples fn syntax_color(kind: Kind, theme: &Theme) -> PaintId {
// its brighter/darker helpers might suggest -- these are plain 0..255 RGB.
// Catppuccin Mocha, the same values `app/.../Theme.kt` maps onto
// Material's roles, so a block drawn here and the same block drawn by the
// Compose app are the same colour rather than nearly.
const fn mocha(hex: u32) -> UiColor {
UiColor::new(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
255,
)
}
/// Body text: Mocha Text, the Compose app's `onSurface`.
pub const TEXT_COLOR: UiColor = mocha(0xCDD6F4);
/// Inline code, and a fence with no language to highlight it by.
pub const CODE_COLOR: UiColor = mocha(0xCDD6F4);
/// A link. "Blue is what a link is on every Catppuccin surface, and the
/// one colour to leave alone" (`Theme.kt`'s `linkColor`).
pub const LINK_COLOR: UiColor = mocha(0x89B4FA);
/// A list's bullets and numbers: structure rather than words, so the
/// items of a list can be counted without reading them (`listMarkerColor`).
pub const MARKER_COLOR: UiColor = mocha(0xB4BEFE);
/// What every verbatim thing in this app sits on -- Mocha Crust, one step
/// *below* the page rather than above it (`Theme.kt`'s `rawSurface`).
pub const VERBATIM_BACKGROUND: UiColor = mocha(0x11111B);
/// A table's fill: Surface 0, the Compose app's `surfaceVariant`.
pub const TABLE_BACKGROUND: UiColor = mocha(0x313244);
/// A quote's bar and its text: the bar carries the structure, and the
/// words step back one shade from body text so a quote reads as quoted
/// without being hard to read.
pub const QUOTE_BAR_COLOR: UiColor = mocha(0x585B70);
pub const QUOTE_TEXT_COLOR: UiColor = mocha(0xA6ADC8);
const STRIKETHROUGH_COLOR: UiColor = mocha(0x6C7086);
/// Catppuccin Mocha as the highlighter's palette -- the same mapping
/// `Theme.kt`'s `catppuccinSyntax()` uses, so a `kotlin` fence is the same
/// colours in both apps.
fn syntax_color(kind: Kind) -> UiColor {
match kind { match kind {
Kind::Keyword => mocha(0xCBA6F7), Kind::Keyword => theme.syntax_keyword.clone(),
Kind::String => mocha(0xA6E3A1), Kind::String => theme.syntax_string.clone(),
Kind::Literal => mocha(0xFAB387), Kind::Literal => theme.syntax_literal.clone(),
Kind::Comment => mocha(0x6C7086), Kind::Comment => theme.syntax_comment.clone(),
Kind::Metadata => mocha(0xF9E2AF), Kind::Metadata => theme.syntax_metadata.clone(),
Kind::Punctuation => mocha(0xA6ADC8), Kind::Punctuation => theme.syntax_punctuation.clone(),
Kind::Mark => mocha(0x89DCEB), Kind::Mark => theme.syntax_mark.clone(),
} }
} }
/// What a row builder puts *around* a block's text widget. Three, not one #[derive(Debug, Clone, PartialEq, Eq)]
/// per markdown feature -- see the module doc.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockFrame { pub enum BlockFrame {
/// Text and nothing else: a paragraph, a heading, a list, a rule.
Plain, Plain,
/// A dark rounded panel whose text does not wrap -- long lines pan Verbatim { fill: PaintId },
/// sideways, the way `CodeFence.kt`'s `horizontalScroll` does. Carries
/// its own fill, since a fence and a table are drawn on different
/// ones.
Verbatim { fill: UiColor },
/// A coloured bar down the left edge and an indent past it.
Quote, Quote,
} }
/// The frame a block kind is drawn in. Pure, and the *only* place the pub fn frame_of(kind: BlockKind, theme: &Theme) -> BlockFrame {
/// mapping is written: a new `BlockKind` shows up here as a compile error
/// rather than silently taking prose's appearance.
pub fn frame_of(kind: BlockKind) -> BlockFrame {
match kind { match kind {
BlockKind::Code => BlockFrame::Verbatim { BlockKind::Code => BlockFrame::Verbatim {
fill: VERBATIM_BACKGROUND, fill: theme.verbatim_surface.clone(),
}, },
BlockKind::Table => BlockFrame::Verbatim { BlockKind::Table => BlockFrame::Verbatim {
fill: TABLE_BACKGROUND, fill: theme.table_surface.clone(),
}, },
BlockKind::Quote => BlockFrame::Quote, BlockKind::Quote => BlockFrame::Quote,
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => { BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
@@ -134,16 +39,12 @@ pub fn frame_of(kind: BlockKind) -> BlockFrame {
} }
} }
/// A tappable range of a block's text and where it points.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Link { pub struct Link {
/// Byte range into [`Rendered::text`].
pub range: Range<usize>, pub range: Range<usize>,
pub url: String, pub url: String,
} }
/// One block, ready to draw. Not `Debug`: `SpanStyle` is not, and adding
/// it there for this would be a change to iris for a test's benefit.
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct Rendered { pub struct Rendered {
pub text: String, pub text: String,
@@ -152,9 +53,6 @@ pub struct Rendered {
} }
impl Rendered { impl Rendered {
/// The link `byte` falls inside, if any -- what a tap resolves
/// through. Half-open, so the offset one past a link's last character
/// (where a tap just after it lands) is *not* in it.
pub fn link_at(&self, byte: usize) -> Option<&Link> { pub fn link_at(&self, byte: usize) -> Option<&Link> {
self.links.iter().find(|l| l.range.contains(&byte)) self.links.iter().find(|l| l.range.contains(&byte))
} }
@@ -178,10 +76,6 @@ fn heading_size(level: HeadingLevel) -> f32 {
} }
} }
/// The bullet at each depth, cycling past the third: a disc, a ring, a
/// square -- the ladder a browser draws, so a nested list is told from its
/// parent by the glyph as well as by the indent. Same three
/// `MarkdownPieces.kt` uses.
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "]; const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
/// A block-level separator inside one block's own text (a list item's /// A block-level separator inside one block's own text (a list item's
@@ -202,15 +96,10 @@ fn ensure_line(out: &mut String) {
} }
} }
/// One top-level block, rendered. `base_size` is the row's ordinary pub fn render_block(block: &Block, base_size: f32, theme: &Theme) -> Rendered {
/// paragraph font size; a heading overrides it per span.
pub fn render_block(block: &Block, base_size: f32) -> Rendered {
match block.kind { match block.kind {
// A table is the one block markdown states as a grid and iris has BlockKind::Table => table_text(&block.source, theme),
// no grid widget for. Rendered as padded monospace instead -- _ => render_markdown(&block.source, base_size, theme),
// see [`table_text`].
BlockKind::Table => table_text(&block.source),
_ => render_markdown(&block.source, base_size),
} }
} }
@@ -218,7 +107,7 @@ pub fn render_block(block: &Block, base_size: f32) -> Rendered {
/// style it. `base_size` is the row's ordinary paragraph font size, needed /// 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 /// only so a heading's override is relative to it rather than a hardcoded
/// absolute the caller cannot retune. /// absolute the caller cannot retune.
pub fn render_markdown(src: &str, base_size: f32) -> Rendered { 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 _ = base_size; // headings use the fixed Material ladder; see `heading_size`
let mut out = String::new(); let mut out = String::new();
let mut spans = Vec::new(); let mut spans = Vec::new();
@@ -234,8 +123,6 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
// `None` for a bulleted one. Depth is this vector's length, which is // `None` for a bulleted one. Depth is this vector's length, which is
// what picks the bullet glyph. // what picks the bullet glyph.
let mut lists: Vec<Option<u64>> = Vec::new(); let mut lists: Vec<Option<u64>> = Vec::new();
// The language of the fence currently open, so `TagEnd::CodeBlock` can
// highlight what was collected between the two.
let mut fence_language: Option<Language> = None; let mut fence_language: Option<Language> = None;
let parser = Parser::new_ext(src, options()); let parser = Parser::new_ext(src, options());
@@ -251,8 +138,6 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
Tag::CodeBlock(kind) => { Tag::CodeBlock(kind) => {
fence_language = match &kind { fence_language = match &kind {
CodeBlockKind::Fenced(info) => { CodeBlockKind::Fenced(info) => {
// Only the first word: "rust,ignore" and
// "console session" are both written.
highlight::fence_language(info.split_whitespace().next()) highlight::fence_language(info.split_whitespace().next())
} }
CodeBlockKind::Indented => None, CodeBlockKind::Indented => None,
@@ -272,17 +157,12 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
} }
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]), _ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
} }
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR)); spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
} }
Tag::List(first) => lists.push(first), Tag::List(first) => lists.push(first),
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out), Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
_ => {} _ => {}
}, },
// Only the tag kinds that pushed onto `open` (Start, above) are
// popped here -- `List`/`Item`/`Paragraph`/`BlockQuote`/`Table`
// and friends push nothing, since they need no span, and must
// not touch this stack or they would pop an unrelated styled
// range still open around them.
Event::End( Event::End(
tag_end @ (TagEnd::Heading(_) tag_end @ (TagEnd::Heading(_)
| TagEnd::Emphasis | TagEnd::Emphasis
@@ -296,8 +176,6 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
continue; continue;
}; };
if matches!(tag_end, TagEnd::CodeBlock) { if matches!(tag_end, TagEnd::CodeBlock) {
// A fence's trailing newline is the fence marker's, not
// the code's -- kept and it draws an empty last line.
while out.ends_with('\n') { while out.ends_with('\n') {
out.pop(); out.pop();
} }
@@ -313,14 +191,18 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()), TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()),
TagEnd::Strong => spans.push(SpanStyle::new(range).bold()), TagEnd::Strong => spans.push(SpanStyle::new(range).bold()),
TagEnd::Strikethrough => { TagEnd::Strikethrough => {
spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR)); spans.push(SpanStyle::new(range).color(theme.strikethrough.clone()));
} }
// An image draws as its alt text until the port has a // An image draws as its alt text until the port has a
// transcript image widget (IRIS_TODO's "scaled // transcript image widget (IRIS_TODO's "scaled
// thumbnail"); marked as a link so it is at least // thumbnail"); marked as a link so it is at least
// followable rather than silently inert. // followable rather than silently inert.
TagEnd::Link | TagEnd::Image => { TagEnd::Link | TagEnd::Image => {
spans.push(SpanStyle::new(range.clone()).color(LINK_COLOR).underline()); spans.push(
SpanStyle::new(range.clone())
.color(theme.link.clone())
.underline(),
);
if let Some(url) = dest { if let Some(url) = dest {
links.push(Link { range, url }); links.push(Link { range, url });
} }
@@ -329,28 +211,23 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
spans.push( spans.push(
SpanStyle::new(range.clone()) SpanStyle::new(range.clone())
.family(Family::Monospace) .family(Family::Monospace)
.color(CODE_COLOR), .color(theme.code.clone()),
); );
// After the monospace span, so the per-token
// colours win where they overlap it.
if let Some(language) = fence_language.take() { if let Some(language) = fence_language.take() {
highlight_into(&mut spans, &out, range, language); highlight_into(&mut spans, &out, range, language, theme);
} }
} }
_ => unreachable!("filtered by the outer match arm"), _ => unreachable!("filtered by the outer match arm"),
} }
} }
Event::Text(text) => out.push_str(&text), Event::Text(text) => out.push_str(&text),
// Inline code (single backticks) is one atomic event with no
// `Start`/`End` pair of its own, unlike a fenced block -- so it
// is spanned directly here instead of through the `open` stack.
Event::Code(text) => { Event::Code(text) => {
let start = out.len(); let start = out.len();
out.push_str(&text); out.push_str(&text);
spans.push( spans.push(
SpanStyle::new(start..out.len()) SpanStyle::new(start..out.len())
.family(Family::Monospace) .family(Family::Monospace)
.color(CODE_COLOR), .color(theme.code.clone()),
); );
} }
Event::SoftBreak => out.push(' '), Event::SoftBreak => out.push(' '),
@@ -362,7 +239,7 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
Event::TaskListMarker(done) => { Event::TaskListMarker(done) => {
let start = out.len(); let start = out.len();
out.push_str(if done { "[x] " } else { "[ ] " }); out.push_str(if done { "[x] " } else { "[ ] " });
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR)); spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
} }
Event::End(TagEnd::List(_)) => { Event::End(TagEnd::List(_)) => {
lists.pop(); lists.pop();
@@ -373,9 +250,6 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
while out.ends_with('\n') { while out.ends_with('\n') {
out.pop(); out.pop();
} }
// A span left pointing past the text a later trim shortened would draw
// against nothing; markdown that ends inside an open emphasis is
// ordinary mid-stream input, not a defect.
spans.retain(|s| s.range.end <= out.len()); spans.retain(|s| s.range.end <= out.len());
links.retain(|l| l.range.end <= out.len()); links.retain(|l| l.range.end <= out.len());
Rendered { Rendered {
@@ -385,25 +259,16 @@ pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
} }
} }
/// The same option set `client_core::markdown_blocks` splits with, so a
/// block boundary there and the styling here cannot disagree about what
/// the source means.
fn options() -> Options { fn options() -> Options {
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
} }
/// `client_core::highlight`'s spans for the code at `range` inside `text`,
/// appended to `spans`.
///
/// The highlighter indexes **chars** and `SpanStyle` indexes **bytes**
/// (`highlight`'s module doc), so the offsets are walked once rather than
/// converted per span -- a fence is scanned on every delta that lands in
/// it, and it is the only block a delta re-renders.
pub(crate) fn highlight_into( pub(crate) fn highlight_into(
spans: &mut Vec<SpanStyle>, spans: &mut Vec<SpanStyle>,
text: &str, text: &str,
range: Range<usize>, range: Range<usize>,
language: Language, language: Language,
theme: &Theme,
) { ) {
let code = &text[range.clone()]; let code = &text[range.clone()];
// char index -> byte offset within `code`, plus the end, so a span's // char index -> byte offset within `code`, plus the end, so a span's
@@ -427,38 +292,19 @@ pub(crate) fn highlight_into(
spans.push( spans.push(
SpanStyle::new(range.start + start..range.start + end) SpanStyle::new(range.start + start..range.start + end)
.family(Family::Monospace) .family(Family::Monospace)
.color(syntax_color(span.kind)), .color(syntax_color(span.kind, theme)),
); );
} }
} }
/// The widest a table column is allowed to get before its cells wrap
/// inside it, in characters. Chosen the way `Markdown.kt`'s 136dp
/// `tableCellWidth` was -- what fits three columns across a phone -- but
/// counted in monospace characters, which is the unit a padded table has:
/// three 28-character columns plus separators is about 90 characters,
/// which is what a 16pt mono face gives on a 1080px phone before the
/// sideways pan starts.
const TABLE_MAX_COL: usize = 28; const TABLE_MAX_COL: usize = 28;
/// A GFM table as **padded monospace columns**, with the header bold and a pub fn table_text(src: &str, theme: &Theme) -> Rendered {
/// rule under it.
///
/// iris has no grid widget, and building one for the one block kind that
/// needs it would be a widget per markdown feature -- what this crate's
/// module doc says it will not do. A monospace face makes character counts
/// and pixel widths the same thing, so padding each cell to its column's
/// width *is* alignment, the column widths are measured from the cells,
/// and the block reuses `BlockFrame::Verbatim`'s sideways pan for a table
/// too wide to fit. docs/DECISIONS.md, 2026-09-06, has what this trades.
pub fn table_text(src: &str) -> Rendered {
let rows = table_cells(src); let rows = table_cells(src);
if rows.is_empty() { if rows.is_empty() {
return Rendered::default(); return Rendered::default();
} }
let columns = rows.iter().map(Vec::len).max().unwrap_or(0); let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
// Each cell wrapped to the cap first, so a column's width is the
// widest *line* it will actually draw rather than the longest cell.
let wrapped: Vec<Vec<Vec<String>>> = rows let wrapped: Vec<Vec<Vec<String>>> = rows
.iter() .iter()
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect()) .map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
@@ -491,8 +337,6 @@ pub fn table_text(src: &str) -> Rendered {
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str); let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
let text = text.unwrap_or(""); let text = text.unwrap_or("");
out.push_str(text); out.push_str(text);
// The last column is not padded: trailing spaces widen
// the block's measured width for nothing.
if c + 1 < widths.len() { if c + 1 < widths.len() {
for _ in text.chars().count()..*width { for _ in text.chars().count()..*width {
out.push(' '); out.push(' ');
@@ -506,7 +350,7 @@ pub fn table_text(src: &str) -> Rendered {
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1); let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
let rule_start = out.len(); let rule_start = out.len();
out.extend(std::iter::repeat_n('\u{2500}', rule)); out.extend(std::iter::repeat_n('\u{2500}', rule));
spans.push(SpanStyle::new(rule_start..out.len()).color(QUOTE_BAR_COLOR)); spans.push(SpanStyle::new(rule_start..out.len()).color(theme.quote_bar.clone()));
} }
} }
Rendered { Rendered {
@@ -516,7 +360,6 @@ pub fn table_text(src: &str) -> Rendered {
} }
} }
/// The cells of a GFM table, row by row, as their plain text.
fn table_cells(src: &str) -> Vec<Vec<String>> { fn table_cells(src: &str) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> = Vec::new(); let mut rows: Vec<Vec<String>> = Vec::new();
let mut cell = String::new(); let mut cell = String::new();
@@ -543,10 +386,6 @@ fn table_cells(src: &str) -> Vec<Vec<String>> {
rows rows
} }
/// `text` broken onto lines of at most `width` characters, at spaces where
/// there are any. A word longer than the column is left over-long rather
/// than cut mid-word: the column then widens for it, which is visible and
/// correct, where cutting would silently lose characters.
fn wrap_cell(text: &str, width: usize) -> Vec<String> { fn wrap_cell(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new(); let mut lines = Vec::new();
let mut line = String::new(); let mut line = String::new();
@@ -567,7 +406,37 @@ fn wrap_cell(text: &str, width: usize) -> Vec<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use client_core::markdown_blocks::split_blocks; 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 { fn block(src: &str) -> Rendered {
let blocks = split_blocks(src); let blocks = split_blocks(src);
@@ -601,8 +470,6 @@ mod tests {
assert_eq!(heading.font_size, Some(24.0)); assert_eq!(heading.font_size, Some(24.0));
} }
/// Every level draws at its own size, so two levels of nesting are
/// never the same -- `Markdown.kt`'s reason for the ladder.
#[test] #[test]
fn every_heading_level_is_a_different_size() { fn every_heading_level_is_a_different_size() {
let mut sizes = Vec::new(); let mut sizes = Vec::new();
@@ -653,23 +520,15 @@ mod tests {
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len())); assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
} }
/// The half the change had no reason to touch: a fence in a language
/// the highlighter has no rules for must be plain rather than
/// coloured by the nearest language's (`CodeFence.kt`'s
/// `fenceLanguage` doc).
#[test] #[test]
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() { fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
let r = block("```brainfuck\nlet x = 1;\n```"); let r = block("```brainfuck\nlet x = 1;\n```");
assert_eq!(r.text, "let x = 1;"); assert_eq!(r.text, "let x = 1;");
assert_eq!(r.spans.len(), 1); assert_eq!(r.spans.len(), 1);
// `Family` is not `Debug`, so this is `assert!` rather than
// `assert_eq!`.
assert!(r.spans[0].family == Some(Family::Monospace)); assert!(r.spans[0].family == Some(Family::Monospace));
assert_eq!(r.spans[0].color, Some(CODE_COLOR)); assert_eq!(r.spans[0].color, Some(code_color()));
} }
/// Multi-byte characters are where a char-indexed highlighter and a
/// byte-indexed span list disagree if the conversion is missing.
#[test] #[test]
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() { fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
let r = block("```rust\nlet s = \"café ☕\"; // é\n```"); let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
@@ -708,7 +567,7 @@ mod tests {
let markers: Vec<_> = r let markers: Vec<_> = r
.spans .spans
.iter() .iter()
.filter(|s| s.color == Some(MARKER_COLOR)) .filter(|s| s.color == Some(marker_color()))
.map(|s| r.text[s.range.clone()].to_string()) .map(|s| r.text[s.range.clone()].to_string())
.collect(); .collect();
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]); assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
@@ -721,7 +580,7 @@ mod tests {
let markers: Vec<_> = r let markers: Vec<_> = r
.spans .spans
.iter() .iter()
.filter(|s| s.color == Some(MARKER_COLOR)) .filter(|s| s.color == Some(marker_color()))
.map(|s| r.text[s.range.clone()].to_string()) .map(|s| r.text[s.range.clone()].to_string())
.collect(); .collect();
assert_eq!(markers, ["3. ", "4. "]); assert_eq!(markers, ["3. ", "4. "]);
@@ -763,8 +622,6 @@ mod tests {
assert_eq!(&r.text[bold.range.clone()], "a bb"); assert_eq!(&r.text[bold.range.clone()], "a bb");
} }
/// The fixture's own table shape: a long cell wraps inside its column
/// instead of making the row one enormous line.
#[test] #[test]
fn a_long_table_cell_wraps_inside_its_column() { fn a_long_table_cell_wraps_inside_its_column() {
let long = "one two three four five six seven eight nine ten eleven twelve"; let long = "one two three four five six seven eight nine ten eleven twelve";
+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,
);
}
+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
File renamed without changes.
@@ -280,7 +280,7 @@ fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
* One row of `GET /sessions/{id}/subagents`, oldest first. * One row of `GET /sessions/{id}/subagents`, oldest first.
* *
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so * A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
* this carries only what a card needs to draw and to open it; see SUBAGENTS.md. [status] is * this carries only what a card needs to draw and to open it; see docs/SUBAGENTS.md. [status] is
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be * "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
* running, and the list says so rather than reporting a state that cannot hold. * running, and the list says so rather than reporting a state that cannot hold.
*/ */
@@ -49,7 +49,7 @@ private sealed class Screen {
/** /**
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and * One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent * docs/SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* is opened from the session list's card rather than from inside the session it belongs to. * is opened from the session list's card rather than from inside the session it belongs to.
*/ */
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary) data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
@@ -4,8 +4,8 @@ import android.content.Context
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
/** /**
* P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): an in-process * P0's benchmark gate (see docs/RUST.md and the 2026-09-05 decision): an in-process fake of the
* fake of the backend, so the `bench` build type can drive a real session screen -- the real * backend, so the `bench` build type can drive a real session screen -- the real
* [TranscriptSource], the real fold, the real paging -- with no server and no network permission. * [TranscriptSource], the real fold, the real paging -- with no server and no network permission.
* *
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else * Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
@@ -24,7 +24,7 @@ object BenchFixture {
const val FIXTURE_PORT = 1 const val FIXTURE_PORT = 1
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */ /** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
private const val BACKLOG_COUNT = 3200 private const val BACKLOG_COUNT = 3202
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench") val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
@@ -478,7 +478,7 @@ private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
} }
/** /**
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function * The subcard's word for a subagent's status -- see docs/SUBAGENTS.md's "Wire shape". Its own function
* rather than a branch inside [StatusText], because a subagent's three states are not that * rather than a branch inside [StatusText], because a subagent's three states are not that
* composable's five: "exited" reads as "finished" here, since its process was always its parent's * composable's five: "exited" reads as "finished" here, since its process was always its parent's
* and never something of its own to have merely stopped. * and never something of its own to have merely stopped.
@@ -295,6 +295,12 @@ fun SessionScreen(
// Which memory notes are open, by the note's own text. Held here rather than in the card so a // Which memory notes are open, by the note's own text. Held here rather than in the card so a
// note opened and scrolled past is still open on the way back. // note opened and scrolled past is still open on the way back.
var openMemories by remember { mutableStateOf(setOf<String>()) } var openMemories by remember { mutableStateOf(setOf<String>()) }
// Which capped blocks the reader has asked to see whole: a tool call's input or output
// ([Capped]), and long messages, by the row key that identifies them. Held here rather than in
// the card or the row for `openMemories`' reason -- something opened and scrolled past is still
// open on the way back, and a lazy list drops the composition of anything off screen.
var shownWholeCalls by remember { mutableStateOf(setOf<Capped>()) }
var shownWholeRows by remember { mutableStateOf(setOf<Any>()) }
// The image being looked at full screen, by ref. Here rather than in the row that drew the // The image being looked at full screen, by ref. Here rather than in the row that drew the
// thumbnail: a row regrouped underneath the reader takes its whole subtree with it. // thumbnail: a row regrouped underneath the reader takes its whole subtree with it.
var fullImage by remember { mutableStateOf<String?>(null) } var fullImage by remember { mutableStateOf<String?>(null) }
@@ -382,12 +388,14 @@ fun SessionScreen(
// Bumped when a cold reply's parses become ready, so the flatten runs again and can split it. // Bumped when a cold reply's parses become ready, so the flatten runs again and can split it.
var warmedTick by remember { mutableIntStateOf(0) } var warmedTick by remember { mutableIntStateOf(0) }
val units = val units =
remember(rows, expandedNotes, warmedTick) { transcriptUnits(rows, replies, expandedNotes) } remember(rows, expandedNotes, warmedTick, shownWholeRows) {
transcriptUnits(rows, replies, expandedNotes, shownWholeRows)
}
// The reply that just finished streaming is the one row whose parses nobody has made: pages // The reply that just finished streaming is the one row whose parses nobody has made: pages
// warm before their fold lands, but nothing warms live deltas. Off the composing thread, then // warm before their fold lands, but nothing warms live deltas. Off the composing thread, then
// the tick re-flattens, so settling never costs a whole-message parse in a frame. // the tick re-flattens, so settling never costs a whole-message parse in a frame.
LaunchedEffect(rows) { LaunchedEffect(rows, shownWholeRows) {
val cold = unwarmedReplies(rows, replies) val cold = unwarmedReplies(rows, replies, shownWholeRows)
if (cold.isNotEmpty()) { if (cold.isNotEmpty()) {
warm(replies, cold) warm(replies, cold)
warmedTick++ warmedTick++
@@ -550,6 +558,20 @@ fun SessionScreen(
toggle() toggle()
} }
/**
* [toggleAnchored] for a control that sits *below* the row it grows -- a capped message's "Show
* all", which is its own list item under the message it reveals.
*
* Always the top edge, with no reading of which half was touched: the control is at the bottom
* of the row by construction, and the whole point of pressing it is that the text just above it
* continues. Held from the bottom instead, the revealed lines would push everything the reader
* had been reading up off the screen and leave them at the end of the message.
*/
fun expandAnchored(key: Any, reveal: () -> Unit) = expanding {
topEdgeHeld.key = key
reveal()
}
/** /**
* Whether the row holding transcript position [seq] is loaded, with older history behind it. * Whether the row holding transcript position [seq] is loaded, with older history behind it.
* *
@@ -1486,6 +1508,15 @@ fun SessionScreen(
) { unit -> ) { unit ->
when (unit) { when (unit) {
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies) is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
is TranscriptUnit.ShowAll ->
ShowAllRow(unit.lines) {
// Anchored like every other control that changes a row's
// height: the reader is looking at the row this belongs to, and
// it is about to get much taller.
expandAnchored(unit.row) {
shownWholeRows = shownWholeRows + unit.row
}
}
is TranscriptUnit.PeerHead -> is TranscriptUnit.PeerHead ->
PeerHeadRow( PeerHeadRow(
unit.item, unit.item,
@@ -1576,6 +1607,12 @@ fun SessionScreen(
::openImage, ::openImage,
) )
}, },
isWhole = { it in shownWholeCalls },
onShowAll = { capped ->
toggleAnchored(row) {
shownWholeCalls = shownWholeCalls + capped
}
},
) )
is TranscriptRow.Single -> is TranscriptRow.Single ->
when (val item = row.item) { when (val item = row.item) {
@@ -1621,6 +1658,16 @@ fun SessionScreen(
::openImage, ::openImage,
) )
}, },
isWhole = { part ->
Capped(item.id, part) in shownWholeCalls
},
onShowAll = { part ->
toggleAnchored(row) {
shownWholeCalls =
shownWholeCalls +
Capped(item.id, part)
}
},
) )
is TranscriptItem.QuestionCard -> is TranscriptItem.QuestionCard ->
QuestionRow(item, ::answerAll) QuestionRow(item, ::answerAll)
@@ -0,0 +1,108 @@
package com.example.aiapp
/**
* How much of a long thing the transcript draws before offering the rest behind a tap.
*
* One rule, four surfaces: a tool call's input, its output, and a user or assistant message. Kept
* in one file because four copies would eventually disagree about what "too long" is -- and because
* the Rust app answers the same question with the same numbers (`client-core`'s `text_cap.rs`, the
* other half of this). The two are deliberately identical so that a benchmark comparing the apps is
* comparing renderers rather than policies.
*
* **Lines and bytes both, whichever runs out first**, because they run out on different things: a
* diff is thousands of short lines, a minified file or a base64 blob is one enormous one, and a cap
* counting only one of them draws the whole of the other.
*
* **Cut at the head, keeping the beginning.** A tool's output is read from the top and the line
* saying what went wrong is nearly always the first; a message is read from the top for the obvious
* reason. (A path is identified by its other end -- none of these is a path.)
*/
object TextCap {
/**
* The bound on a verbatim block -- a tool call's input or its output. Short, because this text
* is a machine's and the reader is looking for one line of it.
*/
const val VERBATIM_LINES = 80
const val VERBATIM_BYTES = 4096
/**
* The bound on a message. Larger than a verbatim block's in bytes and smaller in lines: prose
* is read whole and wraps, so a screenful of it is far fewer lines than a screenful of a log,
* and cutting a reply at 80 lines would cut most long answers that nobody would call long.
*/
const val MESSAGE_LINES = 200
const val MESSAGE_BYTES = 16 * 1024
}
/** [text] cut down to a bound, with the line count of the whole of it. See [cutText]. */
data class CutText(
/** What to draw. */
val shown: String,
/**
* The line count of the **whole** text, not of [shown] -- it is what the "Show all N lines"
* offer says, and a reader deciding whether to ask for the rest wants to know how much the rest
* is.
*/
val lines: Int,
)
/**
* [text] cut to [maxLines] lines and [maxBytes] bytes, or `null` when the whole of it fits.
*
* Bytes rather than characters, so that this and the Rust half cut a multi-byte character at the
* same place. UTF-8 is what the wire carries and what `client-core` measures.
*/
fun cutText(text: String, maxLines: Int, maxBytes: Int): CutText? {
require(maxLines > 0 && maxBytes > 0) {
"a cap of nothing shows an empty block and a 'Show all' for every value there is"
}
val bytes = text.toByteArray(Charsets.UTF_8)
var byLines = -1
var seen = 0
for (i in text.indices) {
if (text[i] == '\n') {
seen++
if (seen == maxLines) {
byLines = i
break
}
}
}
val byBytes =
if (bytes.size > maxBytes) {
// Back up to a character boundary. A UTF-8 continuation byte is `10xxxxxx`; cutting on
// one would split a character in half and `String(bytes)` would draw a replacement mark
// where an em dash was.
var end = maxBytes
while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end--
String(bytes, 0, end, Charsets.UTF_8).length
} else {
-1
}
val cut =
when {
byLines >= 0 && byBytes >= 0 -> minOf(byLines, byBytes)
byLines >= 0 -> byLines
byBytes >= 0 -> byBytes
else -> return null
}
return CutText(text.take(cut), lineCount(text))
}
/**
* How many lines [text] holds, counted the way Rust's `str::lines` counts them -- a trailing
* newline ends the last line rather than starting an empty one.
*
* Said here rather than left to `lineSequence().count()`, which disagrees on exactly that case: the
* two apps have to offer "Show all N lines" with the same N for the same message, and a count that
* is one out on every text ending in a newline (which is most tool output) would show it.
*/
fun lineCount(text: String): Int =
when {
text.isEmpty() -> 0
text.endsWith("\n") -> text.count { it == '\n' }
else -> text.count { it == '\n' } + 1
}
/** What a "Show all" offer says, so the wording is one string rather than one per surface. */
fun showAllLabel(lines: Int): String = "Show all $lines lines"
@@ -1,5 +1,6 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -9,6 +10,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.json.JSONObject import org.json.JSONObject
@@ -105,33 +108,112 @@ fun parseToolInput(tool: String, input: String): ToolInput {
* *
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs * The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this. * with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
*
* **Capped.** An `Edit`'s `old_string` and `new_string` arrive here whole and are routinely the
* largest text on the screen, so the input is cut to [TextCap.VERBATIM_LINES] /
* [TextCap. VERBATIM_BYTES] with a "Show all" under it -- one control for both blocks, because the
* subject and the leftover fields are two halves of the same answer to "what was this call given",
* and two would make the reader ask twice. [whole] is the reader having already asked.
*/ */
@Composable @Composable
fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) { fun ToolInputView(
tool: String,
input: String,
modifier: Modifier = Modifier,
whole: Boolean = false,
onShowAll: () -> Unit = {},
) {
val parsed = remember(tool, input) { parseToolInput(tool, input) } val parsed = remember(tool, input) { parseToolInput(tool, input) }
if (parsed.subject == null && parsed.rest.isEmpty()) return if (parsed.subject == null && parsed.rest.isEmpty()) return
val subject = remember(parsed.subject, whole) { capped(parsed.subject, whole) }
val rest =
remember(parsed.rest, whole) {
capped(parsed.rest.takeIf { it.isNotEmpty() }?.joinToString("\n"), whole)
}
RawBlock(modifier) { RawBlock(modifier) {
parsed.subject?.let { subject -> subject.shown?.let { shown ->
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the // Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely. // one being read closely.
Text( Text(
// Highlighted over what is *drawn* rather than over the whole subject, so a cut
// cannot leave a span pointing past the end of the text it styles.
//
// Not cached: a tool's subject is one command line, which lexes in microseconds -- // Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it. // the cache exists for a fence with two hundred lines in it.
remember(subject, parsed.language) { highlight(subject, parsed.language) }, remember(shown, parsed.language) { highlight(shown, parsed.language) },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
softWrap = false, softWrap = false,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) )
} }
parsed.rest.forEach { rest.shown?.let { shown ->
// Never dropped: a field left out would be claiming the tool has no other input when it
// might. Not wrapped, for the subject's reason -- Iris, 2026-09-08: "for 'raw' text
// like
// tool results I think it should not be wrapped".
Text( Text(
it, shown,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp), softWrap = false,
modifier =
Modifier.padding(top = 2.dp)
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
) )
} }
// The count is the whole input's, both blocks together, because that is what the one
// control reveals.
if (subject.cut || rest.cut) {
ShowAllRow(lines = subject.lines + rest.lines, onClick = onShowAll)
}
} }
} }
/** One verbatim block as it will be drawn; see [capped]. */
data class CappedBlock(
/** The text to draw, or `null` when there was none to begin with. */
val shown: String?,
/** The line count of the whole of it. */
val lines: Int,
/** Whether anything was left out. */
val cut: Boolean,
)
/**
* [text] as an open card draws it: the whole of it when [whole], or [TextCap]'s worth otherwise.
*
* `null` in, `null` out, so a caller with nothing to draw reads the same three fields as one with
* something.
*/
private fun capped(text: String?, whole: Boolean): CappedBlock {
if (text == null) return CappedBlock(null, 0, false)
val cut = if (whole) null else cutText(text, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
return when (cut) {
null -> CappedBlock(text, lineCount(text), false)
else -> CappedBlock(cut.shown, cut.lines, true)
}
}
/**
* The "Show all N lines" under a capped block.
*
* It says the count rather than "more" because the reader is deciding whether to ask for it: "Show
* all 4,000 lines" and "Show all 12 lines" are different decisions, and "more" tells them apart not
* at all.
*/
@Composable
fun ShowAllRow(lines: Int, onClick: () -> Unit) {
val label = showAllLabel(lines)
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier.padding(top = 4.dp).clickable(onClick = onClick).semantics {
contentDescription = label
},
)
}
@@ -2,6 +2,7 @@ package com.example.aiapp
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -163,6 +165,9 @@ fun ToolGroup(
onToolToggle: (String) -> Unit, onToolToggle: (String) -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit, image: @Composable (String) -> Unit,
/** Which capped blocks the reader has asked to see whole; see [Capped]. */
isWhole: (Capped) -> Boolean = { false },
onShowAll: (Capped) -> Unit = {},
) { ) {
val heading = "Called ${group.calls.size} tools" val heading = "Called ${group.calls.size} tools"
if (!expanded) { if (!expanded) {
@@ -202,6 +207,8 @@ fun ToolGroup(
onToggle = { onToolToggle(call.id) }, onToggle = { onToolToggle(call.id) },
onAnswer = onAnswer, onAnswer = onAnswer,
image = image, image = image,
isWhole = { part -> isWhole(Capped(call.id, part)) },
onShowAll = { part -> onShowAll(Capped(call.id, part)) },
shape = connectedShape(index, group.calls.size), shape = connectedShape(index, group.calls.size),
) )
} }
@@ -272,6 +279,21 @@ private val GROUP_INSET = 4.dp
/** Enough to read the join as a join rather than as one tall card. */ /** Enough to read the join as a join rather than as one tall card. */
private val GROUP_GAP = 2.dp private val GROUP_GAP = 2.dp
/**
* Which half of an open card a cap and its "Show all" belong to.
*
* The two are capped and revealed independently: opening the whole of a call's input says nothing
* about wanting the whole of its output, and one control revealing both would make the card jump by
* the sum of two things when it was asked about one.
*/
enum class ToolPart {
INPUT,
OUTPUT,
}
/** One capped thing on the screen that the reader may ask to see whole. */
data class Capped(val call: String, val part: ToolPart)
/** /**
* One tool call. * One tool call.
* *
@@ -291,6 +313,11 @@ fun ToolCard(
onToggle: () -> Unit, onToggle: () -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit = {}, image: @Composable (String) -> Unit = {},
/**
* Whether the reader has asked for the whole of this call's input or output; see [ToolPart].
*/
isWhole: (ToolPart) -> Boolean = { false },
onShowAll: (ToolPart) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */ /** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape, shape: Shape = CardDefaults.shape,
) { ) {
@@ -353,11 +380,28 @@ fun ToolCard(
// something answerable; dumping the same JSON above them would be the decision // something answerable; dumping the same JSON above them would be the decision
// stated twice, once unreadably. // stated twice, once unreadably.
if (tool.tool != ASK_USER_QUESTION) { if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp)) ToolInputView(
tool.tool,
tool.input,
Modifier.padding(top = 4.dp),
whole = isWhole(ToolPart.INPUT),
onShowAll = { onShowAll(ToolPart.INPUT) },
)
} }
if (tool.output.isNotEmpty()) { if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall) Text("Output", style = MaterialTheme.typography.labelSmall)
// Capped like the input, and revealed separately from it: a reader who wants
// the whole of a 900-line `new_string` rarely also wants the whole of the build
// log underneath it.
val wholeOutput = isWhole(ToolPart.OUTPUT)
val cut =
remember(tool.output, wholeOutput) {
if (wholeOutput) null
else
cutText(tool.output, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
}
val shown = cut?.shown ?: tool.output
// What the tool printed, on the surface everything verbatim gets and in the // What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is // face it was written for: this is column-aligned far more often than it is
// prose, and a proportional font silently destroys the alignment that carried // prose, and a proportional font silently destroys the alignment that carried
@@ -367,13 +411,23 @@ fun ToolCard(
// often the whole of what a diff or a test run is saying. Remembered against // often the whole of what a diff or a test run is saying. Remembered against
// the text, so a card that is open through a scroll parses once. // the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() } val palette = remember { ansiPalette() }
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) } val styled = remember(shown, palette) { ansiStyled(shown, palette) }
RawBlock(Modifier.padding(top = 2.dp)) { RawBlock(Modifier.padding(top = 2.dp)) {
// Not wrapped, and panning sideways instead -- Iris, 2026-09-08: "for 'raw'
// text like tool results I think it should not be wrapped". Wrapping a
// column-aligned log is what destroys the alignment that carried its
// meaning, one line at a time and only on the long lines.
Text( Text(
styled, styled,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
softWrap = false,
modifier =
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) )
if (cut != null) {
ShowAllRow(cut.lines) { onShowAll(ToolPart.OUTPUT) }
}
} }
} }
} }
@@ -5,7 +5,7 @@ package com.example.aiapp
* *
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and * The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and * [TranscriptCache.session] all take, rather than each growing its own branch between a session and
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id * a subagent -- see docs/SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
* builds one with the one-argument constructor; a subagent's screen supplies both ids. * builds one with the one-argument constructor; a subagent's screen supplies both ids.
*/ */
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) { data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
@@ -130,6 +130,26 @@ sealed class TranscriptUnit {
get() = "u$seq:$ordinal" get() = "u$seq:$ordinal"
} }
/**
* The "Show all N lines" under a message drawn only as far as [TextCap.MESSAGE_LINES].
*
* Its own unit rather than something inside the row above it, because the row above it is a
* *bounded* item now and this is what says so -- and because a control that lives inside the
* thing it reveals moves the moment it is pressed.
*/
data class ShowAll(
override val seq: Long,
override val ordinal: Int,
/** The row this belongs to; what goes into the set of rows shown whole. */
val row: Any,
/** The line count of the whole message, which is what the offer says. */
val lines: Int,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "s$row"
}
/** One memory note of a settled reply; see [MemoryNote]. */ /** One memory note of a settled reply; see [MemoryNote]. */
data class Memory( data class Memory(
override val seq: Long, override val seq: Long,
@@ -142,6 +162,36 @@ sealed class TranscriptUnit {
} }
} }
/**
* A message row cut to [TextCap]'s worth of itself, and the line count of the whole of it.
*
* The cut happens **before** the flatten below decides how to draw the row, so everything after it
* -- pieces, chunks, warming -- sees a shorter message and needs to know nothing about caps. The
* shortened row keeps its key and its seq, so the list's identity and every saved scroll anchor are
* untouched by a reader opening or closing one.
*
* A reply still arriving is never capped: it grows by deltas, and a row that stopped growing at two
* hundred lines while the model was plainly still writing would read as the stream having died.
* `iris`'s `row::build_row` states the same rule for the same reason.
*/
private fun capRow(row: TranscriptRow, shownWhole: Set<Any>): Pair<TranscriptRow, Int?> {
val item = (row as? TranscriptRow.Single)?.item ?: return row to null
if (row.key in shownWhole) return row to null
val cut =
when {
item is TranscriptItem.UserMsg ->
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
it to TranscriptRow.Single(item.copy(text = it.shown))
}
item is TranscriptItem.AssistantMsg && item.settled ->
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
it to TranscriptRow.Single(item.copy(text = it.shown))
}
else -> null
} ?: return row to null
return cut.second to cut.first.lines
}
/** /**
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the * The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start. * screen, which is what a reversed lazy list calls the start.
@@ -161,11 +211,14 @@ fun transcriptUnits(
rows: List<TranscriptRow>, rows: List<TranscriptRow>,
replies: ParsedReplies, replies: ParsedReplies,
openNotes: Set<Long>, openNotes: Set<Long>,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptUnit> { ): List<TranscriptUnit> {
val started = System.nanoTime() val started = System.nanoTime()
val units = ArrayList<TranscriptUnit>(rows.size) val units = ArrayList<TranscriptUnit>(rows.size)
rows.forEachIndexed { index, row -> rows.forEachIndexed { index, whole ->
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
val (row, hidden) = capRow(whole, shownWhole)
val rowStart = units.size
val item = (row as? TranscriptRow.Single)?.item val item = (row as? TranscriptRow.Single)?.item
if (item is TranscriptItem.PeerNote) { if (item is TranscriptItem.PeerNote) {
val open = item.seq in openNotes val open = item.seq in openNotes
@@ -240,6 +293,16 @@ fun transcriptUnits(
} else { } else {
units += TranscriptUnit.Whole(row, rowGap) units += TranscriptUnit.Whole(row, rowGap)
} }
if (hidden != null) {
units +=
TranscriptUnit.ShowAll(
row.startSeq,
units.size - rowStart,
row.key,
hidden,
BLOCK_SPACING,
)
}
} }
units.reverse() units.reverse()
reportDuplicateKeys(units) reportDuplicateKeys(units)
@@ -266,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 * what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
* ready parses. * ready parses.
*/ */
fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> = fun unwarmedReplies(
rows.mapIndexedNotNull { index, row -> rows: List<TranscriptRow>,
replies: ParsedReplies,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptItem> = rows.mapIndexedNotNull { index, whole ->
// The *capped* row's text, since that is what the flatten will draw and so what has to be
// ready: a capped row draws its head, which is a different string from the message and so a
// different cache entry.
val row = capRow(whole, shownWhole).first
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) } item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
} }
/** /**
* Above this many characters, a user message is drawn in slices rather than as one bubble. * 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.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice" is TranscriptUnit.UserChunk -> "user slice"
is TranscriptUnit.Memory -> "memory note" is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.ShowAll -> "show all"
is TranscriptUnit.Whole -> is TranscriptUnit.Whole ->
when (val row = row) { when (val row = row) {
is TranscriptRow.Tools -> "tool group" is TranscriptRow.Tools -> "tool group"
@@ -0,0 +1,63 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The cap rule, which this app and the Rust one have to answer identically -- these mirror
* `client-core/src/text_cap.rs`'s own tests case for case, because "Show all 4,000 lines" appearing
* in one app and "Show all 4,001" in the other is exactly the kind of difference a benchmark
* comparing the two would report as a rendering difference.
*/
class TextCapTest {
@Test
fun textUnderBothBoundsIsNotCut() {
assertNull(cutText("one\ntwo\nthree", 80, 4096))
}
@Test
fun theLineBoundCutsAtALineBoundary() {
val cut = cutText("a\nb\nc\nd\n", 2, 4096)!!
assertEquals("a\nb", cut.shown)
assertEquals(4, cut.lines, "the count is the whole text's, not the shown part's")
}
/**
* The half the line bound cannot catch: one enormous line, which is what a minified file is.
*/
@Test
fun theByteBoundCutsOneLongLine() {
val cut = cutText("x".repeat(5000), 80, 4096)!!
assertEquals(4096, cut.shown.length)
assertEquals(1, cut.lines)
}
@Test
fun theTighterOfTheTwoBoundsWins() {
val text = "aaaa\n".repeat(100)
assertEquals(100, cutText(text, 80, 100)!!.shown.length)
assertEquals("aaaa\naaaa\naaaa\naaaa", cutText(text, 4, 4096)!!.shown)
}
/**
* A cut landing inside a multi-byte character has to back up to the boundary. The Rust half
* measures in UTF-8 bytes, so this one does too -- counting UTF-16 characters instead would cut
* the same text at a different place in every message with an em dash in it.
*/
@Test
fun aCutInsideAMultibyteCharacterBacksUpToTheBoundary() {
val cut = cutText("é".repeat(100), 80, 11)!!
assertEquals("é".repeat(5), cut.shown, "11 bytes lands mid-character; 10 is the cut")
}
/** A trailing newline ends the last line rather than starting an empty one; see [lineCount]. */
@Test
fun lineCountMatchesRustsStrLines() {
assertEquals(0, lineCount(""))
assertEquals(1, lineCount("a"))
assertEquals(1, lineCount("a\n"))
assertEquals(2, lineCount("a\nb"))
assertEquals(2, lineCount("a\nb\n"))
}
}
+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 at `assets/` (`app/androidApp/build.gradle.kts`'s `sourceSets { getByName("bench") }`), and a Python
script and a markdown file have no business inside an APK: script and a markdown file have no business inside an APK:
- `transcript.jsonl` -- 3,601 events. The first 3,200 (`BACKLOG_COUNT`) are the scrolled-back - `transcript.jsonl` -- 3,603 events. The first 3,202 (`BACKLOG_COUNT`) are the scrolled-back
history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output, history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output,
assistant replies built from headings, bold/italic/inline code, a link, fenced code blocks that assistant replies built from headings, bold/italic/inline code, a link, fenced code blocks that
rotate through rust/kotlin/python/sh/json/toml, a markdown table, two embedded images, and rotate through rust/kotlin/python/sh/json/toml, a markdown table, two embedded images, and
periodic `usageDelta`/`compacted` events. The remaining 400 (`STREAM_COUNT`) are not part of the periodic `usageDelta`/`compacted` events. The remaining 400 (`STREAM_COUNT`) are not part of the
opening window -- both bench harnesses replay them at a fixed rate (20/s) through the same live opening window -- both bench harnesses replay them at a fixed rate (20/s) through the same live
fold path a real SSE reply arrives on, which is P0's "streaming phase." fold path a real SSE reply arrives on, which is P0's "streaming phase."
**The streamed reply has a blank line every few deltas** (2026-09-09), so the markdown block a
delta lands in stays the size a real reply's blocks are -- 53 blocks, longest 502 characters,
against a measured p50 of 147 and a largest-ever 1,580 over 7,706 blocks of real assistant
messages. It used to be one run-on 14,888-character block, and since a row re-shapes the block a
delta lands in, every delta re-shaped all of it: quadratic in the reply's length, and 9.5ms of
frame time on a phone spent on a shape that does not occur. docs/RUST.md's "Incremental text"
has the measurements.
**The run-on message is kept**, as the first two events of the backlog: 14,824 characters in a
single block, just under `text_cap`'s 16 KiB `MESSAGE_BYTES` so it draws in full rather than
behind a "Show all". It is deliberately *not* streamed -- the repeated-reshape pathology needs a
growing block, and that lives in `app-rust/tests/frame_profile.rs` where it can be iterated on
in a second rather than in a two-minute phone run. It is emitted with the random state saved and
restored around it, so adding it left every other backlog event byte-identical; that is what
keeps `phone_screen.rs`'s recorded gestures landing on the content they were recorded against.
- `bench1.png`, `bench2.png` -- tiny (8x8) flat-colour PNGs, base64-free on disk but served the - `bench1.png`, `bench2.png` -- tiny (8x8) flat-colour PNGs, base64-free on disk but served the
same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two
`"type":"image"` events in the transcript. `"type":"image"` events in the transcript.
`BACKLOG_COUNT` lives in three places and moves in all of them or none: here, `app-rust/src/ui/
fixture.rs` and `BenchFixture.kt`. The split is by line index, so a stale copy makes that app open
a different half of the file.
Regenerate after changing the shape (a new event type, a different backlog/stream split) with Regenerate after changing the shape (a new event type, a different backlog/stream split) with
`./generate.py`, and commit the result -- it is checked in rather than generated at build time so `./generate.py`, and commit the result -- it is checked in rather than generated at build time so
both apps' bench builds embed the identical bytes without needing this script at build time. both apps' bench builds embed the identical bytes without needing this script at build time.
File diff suppressed because it is too large. Load diff
+65 -3
View File
@@ -26,8 +26,40 @@ import zlib
from pathlib import Path from pathlib import Path
SEED = 20260905 SEED = 20260905
BACKLOG_COUNT = 3200 # The stress message below, two events, added to the 3,200 the backlog used to be. Both apps
# hardcode this to split the file by line index (`fixture.rs`'s `BACKLOG_COUNT`,
# `BenchFixture.kt`'s), so it moves in three places or none.
STRESS_EVENTS = 2
BACKLOG_COUNT = 3200 + STRESS_EVENTS
STREAM_COUNT = 400 STREAM_COUNT = 400
# How many deltas fold into one markdown block of the streamed reply.
#
# **The blank line between them is the point** (2026-09-09). Until this run the streaming tail
# appended `paragraph(5) + " "` STREAM_COUNT times with no blank line anywhere, so all 400 deltas
# folded into a *single* 14,888-character block -- and a row re-shapes the block a delta lands in
# (`RowBlocks::apply_delta`), so every delta re-shaped the whole thing. That is quadratic in the
# reply's length, and it put 9.5ms of frame time on Iris's phone into a shape that does not occur:
# measured over 7,706 top-level blocks from 3,675 real assistant messages, block length is p50 147
# characters, p90 449, p99 836, largest 1,580, nothing above 4,000. `paragraph(5)` is ~35
# characters, so 4-12 of them per block lands in that range.
#
# The run-on version is kept, as STRESS_CHARS below -- Iris, 2026-09-09: "let's switch to new
# lines for the test, and also let's keep the single line around for stress".
DELTAS_PER_BLOCK = (4, 12)
# One deliberately pathological message in the backlog: a single markdown block with no blank line
# in it, the shape the streaming tail used to have. Sized just under `text_cap`'s MESSAGE_BYTES
# (16 KiB) so it is drawn in full rather than behind a "Show all" -- which makes it a genuine
# stress for one-shot shaping and puts a row right on the cap boundary, where nothing else is.
#
# It is *not* streamed: the repeated-reshape pathology needs a growing block, and that lives in
# `app-rust/tests/frame_profile.rs`'s `what_reshaping_a_growing_message_costs`, where it can be
# iterated on in a second rather than in a two-minute phone run. Note that the cap would not save
# a real one anyway -- `row::build_row`'s `cap` is deliberately `false` for the live tail, because
# a row that grew while capped would appear to stop growing, so a streamed block's shaping cost
# has no ceiling.
STRESS_CHARS = 14_800
HERE = Path(__file__).resolve().parent / "assets" HERE = Path(__file__).resolve().parent / "assets"
random.seed(SEED) random.seed(SEED)
@@ -122,6 +154,26 @@ def main():
emit("status", state="running") emit("status", state="running")
emit("settings", model="bench-model", permissionMode="auto") emit("settings", model="bench-model", permissionMode="auto")
# The stress message, emitted first and with the random state put back afterwards, so that
# adding it is **purely additive**: every turn the loop below generates is byte-identical to
# what it generated before this existed, and only the streaming tail changed. That matters
# because the fixture's opening view is what `phone_screen.rs`'s recorded gestures press --
# `a_long_press_and_drag_selects_text` drives a real recording at (300, 1000) and fails the
# moment different content lands under it. `ts` still shifts by the two draws, which nothing
# reads.
rng_state = random.getstate()
emit(
"userMessage",
text="And the pathological one: a reply with no paragraph break in it.",
id=None,
attachments=[],
)
run_on = ""
while len(run_on) < STRESS_CHARS:
run_on += paragraph(5) + " "
emit("assistantText", delta=run_on.rstrip())
random.setstate(rng_state)
image_refs = [] image_refs = []
turn = 0 turn = 0
while seq <= BACKLOG_COUNT: while seq <= BACKLOG_COUNT:
@@ -167,10 +219,20 @@ def main():
trigger="auto", trigger="auto",
) )
# The streaming-phase tail: one long reply, built entirely from text deltas, the shape a # The streaming-phase tail: one reply built entirely from text deltas, the shape a bench
# bench harness replays at a fixed events/sec through the live fold path. # harness replays at a fixed events/sec through the live fold path -- with a blank line every
# few deltas, so the block a delta lands in stays the size a real reply's blocks are. See
# DELTAS_PER_BLOCK for what the alternative measured.
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[]) emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
until_break = random.randint(*DELTAS_PER_BLOCK)
while seq <= BACKLOG_COUNT + STREAM_COUNT: while seq <= BACKLOG_COUNT + STREAM_COUNT:
until_break -= 1
if until_break <= 0:
# The break rides on the last delta of the block rather than being an event of its
# own, so STREAM_COUNT still counts deltas a reader sees text arrive from.
emit("assistantText", delta=paragraph(5) + "\n\n")
until_break = random.randint(*DELTAS_PER_BLOCK)
else:
emit("assistantText", delta=paragraph(5) + " ") emit("assistantText", delta=paragraph(5) + " ")
emit("status", state="idle") emit("status", state="idle")
+5 -5
View File
@@ -1,11 +1,11 @@
plugins { alias(libs.plugins.androidApplication) } plugins { alias(libs.plugins.androidApplication) }
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge // E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
// into Rust (`../../android-shell`). Deliberately its own module rather // into Rust (`../../app-rust`, the `shell` feature). Deliberately its own module rather
// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines // than a rewrite of `:androidApp` in place -- that module is ~13,000 lines
// of working Compose UI this experiment does not touch, and the two can be // of working Compose UI this experiment does not touch, and the two can be
// installed side by side on the same development device (see // installed side by side on the same development device (see
// `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme // `settings.SCHEME`'s doc in `app-rust/src/shell` for why the deep-link scheme
// and Keystore alias are not the production app's). No Compose plugin, no // and Keystore alias are not the production app's). No Compose plugin, no
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain // Kotlin source of its own: `MainActivity`/`NotificationService` are plain
// Java, and the CA constant below is generated as Java too. // Java, and the CA constant below is generated as Java too.
@@ -13,7 +13,7 @@ plugins { alias(libs.plugins.androidApplication) }
// The CA this build pins is baked in the same way `androidApp`'s does -- // The CA this build pins is baked in the same way `androidApp`'s does --
// see that module's `build.gradle.kts` comment for the reasoning (the // see that module's `build.gradle.kts` comment for the reasoning (the
// trust boundary follows the machine that builds, never a pasted copy). // trust boundary follows the machine that builds, never a pasted copy).
// `PinnedCa.java`'s package must match `android-shell`'s // `PinnedCa.java`'s package must match `app-rust/src/shell`'s
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`). // `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
val pinnedCaPath: String = val pinnedCaPath: String =
System.getenv("AI_APP_CA") System.getenv("AI_APP_CA")
@@ -150,12 +150,12 @@ androidComponents {
dependencies { dependencies {
// The Keystore-sealed enrollment (ServerStore/ServerSettings) -- // The Keystore-sealed enrollment (ServerStore/ServerSettings) --
// android-shell's settings.rs calls into this Kotlin class directly // the shell bridge's settings.rs calls into this Kotlin class directly
// over JNI rather than re-sealing the token in Rust; see that file's // over JNI rather than re-sealing the token in Rust; see that file's
// module doc. // module doc.
implementation(project(":link")) implementation(project(":link"))
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/ // NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
// ServiceCompat -- android-shell's notify.rs calls these classes over // ServiceCompat -- the shell bridge's notify.rs calls these classes over
// JNI so the pre-26 fallback behaviour (no channels) lives once, in // JNI so the pre-26 fallback behaviour (no channels) lives once, in
// the library that already has it, rather than being re-derived as a // the library that already has it, rather than being re-derived as a
// set of Build.VERSION.SDK_INT branches in Rust. // set of Build.VERSION.SDK_INT branches in Rust.
@@ -17,7 +17,7 @@ import android.widget.Toast;
*/ */
public class MainActivity extends Activity { public class MainActivity extends Activity {
static { static {
System.loadLibrary("android_shell"); System.loadLibrary("ai_app");
} }
@Override @Override
@@ -14,7 +14,7 @@ import android.os.IBinder;
*/ */
public class NotificationService extends Service { public class NotificationService extends Service {
static { static {
System.loadLibrary("android_shell"); System.loadLibrary("ai_app");
} }
@Override @Override
+16 -1
View File
@@ -404,12 +404,27 @@ done
# Percent-encoded because the app URL-decodes the deep link's query: a # Percent-encoded because the app URL-decodes the deep link's query: a
# token with '+' in it enrols as one with a space, and nothing reports it. # token with '+' in it enrols as one with a space, and nothing reports it.
enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN") enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN")
# The CA rides in the link (`wg_app_link::enroll::ca_param`: base64url of
# the DER, which needs no percent-encoding). The Compose app ignores it and
# pins the copy its APK was built with; the iris app has no baked copy at
# all -- it is cross-compiled and could be pointed at any machine -- so
# without this it enrols and then trusts nothing. Minted here rather than by
# `--enroll-link` because this token is the sandbox's own, carried across
# restarts so the emulator stays enrolled (see the top of this file).
ca=$(python3 - "$CERTS/ca.pem" <<'CA'
import base64, sys
pem = open(sys.argv[1]).read()
body = pem.split("-----BEGIN CERTIFICATE-----")[1].split("-----END CERTIFICATE-----")[0]
der = base64.b64decode("".join(body.split()))
print(base64.urlsafe_b64encode(der).decode().rstrip("="))
CA
)
cat <<INFO cat <<INFO
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB) sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
enrol the emulator (once; it survives sandbox restarts): enrol the emulator (once; it survives sandbox restarts):
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc'" adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc&ca=$ca'"
drive it: drive it:
./ui-sandbox.sh spawn [title] an echo session; prints its id ./ui-sandbox.sh spawn [title] an echo session; prints its id
-981
View File
@@ -1,981 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"pulldown-cmark",
"serde",
"serde_json",
"tempfile",
"ureq",
]
[[package]]
name = "cookie"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "crc32fast"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
dependencies = [
"cfg-if",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "event-model"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "smallvec"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
dependencies = [
"base64",
"cookie_store",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-43
View File
@@ -1,43 +0,0 @@
[package]
name = "client-core"
version = "0.1.0"
edition = "2024"
# The app's pure logic, held once instead of twice: the event model (shared
# with `server/` via `event-model`), the REST + SSE clients for its HTTP
# surface (see `server/src/routes.rs`'s module doc for the table), the
# transcript fold and cache, the markdown block model, the syntax
# highlighter and the ANSI parser. See `docs/CLIENT_CORE.md` for
# what this holds today, what it does not yet, and how it corresponds to
# the Kotlin it replaces.
#
# No UI framework dependency of any kind -- this crate is meant to outlive
# whichever one the app ends up drawing with (see RUST.md).
[dependencies]
event-model = { path = "../event-model" }
serde = { version = "1", features = ["derive"] }
# "raw_value" is `fetch_transcript_lines`'s reason -- it needs the exact
# bytes the server sent, not this crate's own re-serialization of a parsed
# `Value`, so a cached line and a live SSE frame for the same event agree
# byte-for-byte (see that method's doc). "float_roundtrip" is why they
# agree on a `ts` at all -- see server/Cargo.toml's identical comment.
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
# The blocking HTTP client for the REST calls and the long-lived SSE GETs.
# `server/` already depends on ureq for its own outbound HTTPS (the usage
# poll in usage.rs) and it is rustls-backed like the rest of this project's
# TLS, so this reuses that choice rather than pulling in reqwest's async
# stack -- a client that runs one blocking request at a time, the way
# Api.kt's `HttpURLConnection` calls and Sse.kt's blocking read loop do, has
# no need of an async runtime, and RUST.md's brief for this port is
# "lightweight" throughout.
ureq = { version = "3", features = ["json"] }
# The markdown block split (`markdown_blocks`), which has to agree with the
# renderer in `iris/transcript-ui` about where a block begins -- so it is
# the same parser at the same version, rather than a hand-written splitter
# that would drift from it.
pulldown-cmark = "0.13.4"
[dev-dependencies]
tempfile = "3"
-152
View File
@@ -1,152 +0,0 @@
//! What a Rust client needs to reach one enrolled server: host, port and
//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s
//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T`
//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and
//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from
//! the same text a phone would scan as a QR, with no second format
//! invented for it (RUST.md's E4).
//!
//! What this type deliberately does not decide: where it is persisted, and
//! under what file permissions. A phone seals its token in the Android
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/`
//! directory and its own file-mode conventions (MACHINE.md: owner-only,
//! never in the repo). Both are caller-specific, so they stay out of this
//! crate per the code rules' "ask for the least you need" -- see
//! `iris/desktop-app/src/config.rs` for the desktop instance.
use serde::{Deserialize, Serialize};
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
/// with `token` as a bearer header. Does not carry the pinned CA -- that is
/// a public certificate rather than a secret, and where to find it differs
/// by caller (a phone pins the one its APK was built against; a desktop
/// client is told a path).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnrolledServer {
pub host: String,
pub port: u16,
pub token: String,
}
impl EnrolledServer {
/// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not
/// matter; unrecognised keys are ignored). `token` is percent-decoded,
/// since `ui-sandbox.sh` encodes it precisely because a raw token can
/// contain `+`, which turns into a space if left to a naive splitter.
pub fn parse_link(link: &str) -> Result<Self, String> {
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
format!(
"'{link}' has no query string (expected \
aiapp://enroll?host=...&port=...&token=...)"
)
})?;
let mut host = None;
let mut port = None;
let mut token = None;
for pair in query.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
let value = percent_decode(value);
match key {
"host" => host = Some(value),
"port" => port = Some(value),
"token" => token = Some(value),
_ => {}
}
}
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
let port: u16 = port_str
.parse()
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
Ok(Self { host, port, token })
}
/// Where a `client_core::api::UreqTransport` reaches this server.
pub fn base_url(&self) -> String {
format!("https://{}:{}", self.host, self.port)
}
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let Ok(byte) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
out.push(byte);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_host_port_and_token() {
let server =
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
.unwrap();
assert_eq!(
server,
EnrolledServer {
host: "127.0.0.1".to_string(),
port: 8547,
token: "abcDEF123".to_string(),
}
);
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
}
#[test]
fn field_order_does_not_matter() {
let server =
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
.unwrap();
assert_eq!(server.host, "example.com");
assert_eq!(server.port, 443);
assert_eq!(server.token, "tok");
}
#[test]
fn a_percent_encoded_token_is_decoded() {
// ui-sandbox.sh's own reason for encoding: a raw '+' would
// otherwise arrive as a space.
let server =
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
assert_eq!(server.token, "a+b/c");
}
#[test]
fn a_missing_field_is_named_in_the_error() {
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
assert!(
err.contains("token"),
"error should name the missing field: {err}"
);
}
#[test]
fn a_non_numeric_port_is_named_in_the_error() {
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
assert!(
err.contains("port"),
"error should name the offending field: {err}"
);
}
}
Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff. Show more