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

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

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

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

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

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

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

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

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

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

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

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

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

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

A redraw freed its primitives and pushed new ones. Freed slots are not
reusable until the end of the frame -- a layer's draw order still names
them -- and Painter::draw_twice is how a container learns a child's
size, so with containers nested the arena's high-water was the transient
push count rather than the live one: 17 million pushes across 401
deltas, 127,443 slots for 11,569 live primitives, growing linearly with
the transcript. A redraw now gets its old handles back as a recycle pool
(Painter::take_recycled, Primitives::recycle) and writes into the slots
it already holds; the pool is consumed in order and whatever the draw
does not claim is freed when it ends. The arena is exactly the live
count now. The CPU frame improved with it, from p50 2.20ms to 1.39ms on
the stream run, because the freeing and the draw-order renumbering went
away.

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

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

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

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

The rigs move to scripts/rigs/ui-profile, a crate of their own so a
rig's dependencies stay out of the app's -- arena_churn needs bytemuck,
which nothing in ai-app does. arena_churn prints floor, uploaded and
whole side by side per array, because any two of those alone are
misleading and the 122x over-marking above was invisible until all three
were on screen together.
2026-09-09 02:14:51 -04:00
irisandClaude Opus 5 77cee6a8fa The bench fixture streams a reply shaped like a real one, and keeps the run-on as stress
Iris, on the two findings from the incremental-text investigation:
"let's switch to new lines for the test, and also let's keep the single
line around for stress + could be something to try to optimize later."

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

The run-on message is kept as the first two backlog events, 14,824
characters in one block, just under text_cap's 16 KiB so it draws in
full. The *streaming* pathology stays in frame_profile.rs rather than the
fixture: it needs a growing block, and iterating on it there costs a
second instead of a two-minute phone run.

Adding it is purely additive -- the random state is saved and restored
around those two events, so every other backlog event is byte-identical.
That is not tidiness: the first attempt shifted the backlog and broke
`a_long_press_and_drag_selects_text`, which replays a real recording at
(300, 1000) and needs the content it was recorded against to still be
there. BACKLOG_COUNT is 3202 now, in generate.py, fixture.rs and
BenchFixture.kt, which split the file by line index.

And the answer to Iris's question, which the code already had: the newest
message does *not* cap. `build_row`'s `cap` is false for the live tail
because a row that grew while capped would appear to stop growing, and a
reply growing past the cap is never caught either since it grows through
apply_delta. So a streamed block's shaping cost has no ceiling -- ~29ms
per delta at 50k characters, ~58ms at 100k.

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

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

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

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

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

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

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

`missed vsyncs` counted idleness. Every gap between frames was treated as
cadence, so the bench's own pauses read as stutter: 276 for sixteen 300ms
rests between flings, 2410 for twelve hundred 50ms keystroke gaps, 821
for four hundred 50ms stream gaps -- each within a few percent of the
arithmetic. A gap now measures anything only if the frame before it had
asked for another one.

`late` counted the swapchain wait as cost. A well-paced loop spends each
frame blocked in the acquire, so its total sits at exactly one refresh
period and every frame lands on the budget boundary -- 0.4ms of work and
5.7ms of waiting is not a late frame. It is judged on `FrameParts::work`.

And the refresh rate is the larger of what the platform claims and what
the run sustained, because each can only be wrong one way.
`Display.getRefreshRate()` answered 60 for a run that drew 3405 frames in
33.1s, since a phone that varies its rate answers with whatever mode it
is in when asked. The first attempt at measuring it instead took the
fastest tenth of the gaps and reported 88Hz for this repo's 60Hz
emulator, whose app manages 54 -- a budget no frame there could meet,
invented out of the app's best moments, and caught only by running the
corrected report on the emulator before shipping it. A sustained rate is
a floor and cannot do that. Both are printed when they disagree.

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

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

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

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

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

`opt-level` for the Android release build goes from "s" to 3. The table
in RUST.md picked "s" on bytes alone; over the same warm fling eight
times iris's own per-frame work is p90 0.15ms/p99 0.42ms at "s" against
p90 0.09ms/p99 0.26ms at 3, for 1.8 MB of arm64 APK.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three new `Widget` methods carry the handoff:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things found on the way and fixed here:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Queue item ticked.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things this needed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three things worth knowing.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

cargo fmt --all across the touched files.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:27 -04:00
iris 50fe4828a2 Merge branch 'worktree-agent-a27094a7db775552a' into tmp-merge 2026-09-05 21:37:12 -04:00
iris 800da46188 Merge remote-tracking branch 'origin/rustify' into worktree-agent-a27094a7db775552a
# Conflicts:
#	docs/IRIS.md
2026-09-05 21:37:04 -04:00
irisandClaude Fable 5.1 00767eed4d docs: P0's iris half done -- bench feature, emulator smoke run, APK
RUST.md's P0 box gets the iris-half account: the fixture, the scroll/stream
mechanism, the report fields, build commands (all clean), packaging (no
cargo xtask apk yet, so a new Gradle release build type on top of cargo
ndk), and the emulator smoke run's report next to Compose's own. Used a
second, differently-named AVD rather than contend with the session already
on this checkout's own emulator.

DECISIONS.md's P0 entry gets a matching summary bullet. IRIS.md records
AndroidAppState::platform_ready. IRIS_TODO.md notes the one gap found:
no read-only selectable text primitive, so the bench report's TextEdit
picks up a keyboard on tap it has nothing to type into.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:35:51 -04:00
irisandClaude Fable 5.1 683db4908a iris-android-app: a bench feature, P0's iris half
A third AndroidAppState (BenchClient) on top of transcript-screen: embeds
app/bench-fixture/assets/transcript.jsonl with include_str! (no server, no
enrollment), folds the first 3,200 lines through client_core's real
fold_page as the opening backlog, and holds the rest back as a streaming
tail. "Run benchmark" resets FrameReport, animates the same 24-swipe/
6-cycle scroll BenchRun.kt drives (List::scroll in ~60Hz steps, since iris
has no built-in tween), then replays the tail at 20/s through fold_event --
the same fold path a live SSE reply takes -- and shows a report in a
selectable TextEdit. "Copy report" puts it on the clipboard.

The report adds process CPU time (libc::getrusage), peak RSS (/proc/self/
status's VmHWM) and battery current (BatteryManager.getIntProperty via
direct JNI, bench_jni.rs's PlatformHandle) to FrameStats's existing
frames/janky%/percentiles/CPU-GPU-split line -- "unavailable" rather than a
fabricated number wherever the platform can't answer.

build.rs now exits early under the bench feature before requiring a live
server's host/port/token/CA: BenchClient never calls build_transport().
app/build.gradle gains a signed `release` build type (previously only
debug) so the cdylib cargo ndk builds can be packaged for a phone, the same
key app/build-apk.sh generates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:35:44 -04:00
irisandClaude Fable 5.1 8d23a20792 iris: AndroidAppState::platform_ready, a JavaVM+View handle for later JNI calls
Default no-op lifecycle hook, called once from new_peer right after new.
P0's bench build needs to call BatteryManager/ClipboardManager through the
view's own Context from a background thread as well as the UI thread, and
neither a JavaVM nor a GlobalRef to the view was reachable from
AndroidAppState::new before this. Existing implementors (Client,
TranscriptClient) are unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:35:33 -04:00
irisandClaude Fable 5.1 d01c105037 iris: stop requesting compute-shader limits nothing uses
adapter.request_device asked for Limits::default(), which requests
desktop-tier compute-shader limits unconditionally even though nothing in
iris/iris-core creates a ComputePipeline or writes a @compute stage. That
crashed device creation outright on a downlevel GL adapter reporting
OpenGL ES 3.0 (no compute at all) -- the Android emulator's
EMU_GPU=software/force-gles path, and any real GLES-3.0-only device.

New iris_core::device_limits(), shared by both platform backends, zeros
exactly the six max_compute_* fields rather than switching to a downlevel
Limits preset -- downlevel_webgl2_defaults() also zeros
max_storage_buffers_per_shader_stage, which shader.wgsl's vertex stage
needs. rigs/gpu-probe's own mirrored limits were updated to match.

Not verified against the actual SwiftShader-ES-3.0 crash on-device this
pass: the cold boot needed would have force-restarted this checkout's
emulator while another session had its own app running on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:18:30 -04:00
iris 88631f5e8b Merge remote-tracking branch 'origin/rustify' into worktree-agent-a27094a7db775552a
# Conflicts:
#	AGENTS.md
#	server/src/session/driver.rs
2026-09-05 21:10:19 -04:00
irisandClaude Fable 5.1 e6924298bc iris: fix the intermittent touch-scroll dropout (missed ACTION_DOWN hit-test)
Root-caused via temporary logcat tracing (touch events, DragArbiter state,
Selection::drag dispatch), reproduced against a real sandbox session: a
gesture's ACTION_DOWN can land on a row's own padding/gap or its header,
which CursorSense has no sensor over, so the widget that ends up handling
the gesture only ever sees Pressing frames and DragArbiter never gets
press_start -- leaving it stuck in Idle (answers Undecided forever) for the
rest of that gesture. Not the previously-suspected coalesced first
ACTION_MOVE, which is now ruled out.

DragArbiter::is_idle() lets Selection::drag notice a Pressing frame with
no matching press_start and recover the press there instead. Four new unit
tests, one of which fails on the pre-fix code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:05:03 -04:00
irisandClaude Fable 5.1 68b48cfd14 docs: record P0's Compose half (bench build, fixture, smoke run)
RUST.md's P0 box gets the emulator smoke run's report and what's done vs.
left; DECISIONS.md gets a dated summary entry; AGENTS.md's "Checking your
work" and "The rigs" get one paragraph each on the bench build type and
app/bench-fixture/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:04:07 -04:00
irisandClaude Fable 5.1 e6c884a0cd app: fixture-mode session screen and a "Run benchmark" control
BenchFixture.kt/BenchNetwork.kt fake the backend for the bench build: a
URLStreamHandlerFactory installed only under BuildConfig.FIXTURE_MODE
answers TranscriptSource/EventStream's requests from an in-memory copy of
the bundled fixture instead of opening a socket, so the fold, the paging
and uniqueItems under test are the screen's real ones rather than a
shortcut built for this. MainActivity opens straight onto that session
when FIXTURE_MODE is set, with no enrollment and no permission prompts.

BenchRun.kt drives the same scroll loop and streaming phase
transcript-bench.sh/stream-bench.sh drive over ui-trace, but in-process
(24 swipes through the real LazyListState, then 400 fixture events
appended at 20/s through the real live-fold path), and adds process CPU
time, peak RSS and battery current to the render report -- "unavailable"
rather than a fabricated number where the device can't answer.

"Run benchmark" sits beside the existing "Copy" in session settings,
found by that exact label the way every other control here is
(SessionSettingsDialog's onRunBenchmark, null on every build but bench).
debugReport gained an optional `extra` section for this; empty and
invisible on every other build's report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:03:57 -04:00
irisandClaude Fable 5.1 a6cb9a9082 app: a bench build type for P0's benchmark gate
Own application id (.bench suffix) and label ("AI Sessions bench" via a
build-type resValue over the new @string/app_name), release
optimisations, signed with the same key build-apk.sh already generates,
FIXTURE_MODE=true wired through BuildConfig. Its asset source set points
straight at app/bench-fixture/assets rather than a copy under androidApp,
so there is one file to keep in sync with the generator, not two.

build-apk.sh bench builds it; the CA-pinning step is untouched and still
requires a real ca.pem to exist, even though this build never connects --
simplest to let it pin whatever is there rather than special-casing it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:03:44 -04:00
irisandClaude Fable 5.1 0be6a571c4 app/bench-fixture: the synthetic transcript P0's benchmark opens in both apps
Deterministic (seeded), in the app's own event model rather than a real
transcript: 3,601 events split into a 3,200-event opening backlog and a
400-event tail both bench harnesses replay as the streaming phase, with
headings, inline markdown, fenced code in six languages, a table, tool
calls with kilobyte-scale input/output, and two embedded PNGs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:03:34 -04:00
irisandClaude Fable 5.1 bfe93c4188 RUST.md, DECISIONS.md: P0, the phone benchmark gate Iris asked for before P1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 20:31:20 -04:00
irisandClaude Fable 5.1 5b7dc0e4e2 RUST.md, DECISIONS.md, IRIS_TODO.md: the port plan, P1-P7, after iris-over-Masonry
Adds "The port, in order (decided 2026-09-05)" to RUST.md: seven ordered
steps building the app on iris now that the framework is decided, each
naming the Kotlin files it replaces, the client-core pieces it needs
(and which are not yet covered and must be ported first), the missing
iris widgets it needs (recorded in IRIS_TODO.md's new "Build (for the
port)" section), and a pass condition a later agent can run. Ordered by
risk to the daily-use path: session screen parity, then the shell merge
and a real phone install, then root tabs, the file explorer,
settings/enrolment, desktop parity, and the cutover itself.

Crate-shape decision recorded in DECISIONS.md: one UI crate, app-ui,
grown out of transcript-ui rather than started beside it, with
desktop-app/android-app as thin entry points over it and platform-only
code staying in the E3/E5 Java shell.

Updates RUST.md's "Where things stand" and "For the next agent" to point
at P1 rather than the now-closed framework decision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 20:30:02 -04:00
irisandClaude Fable 5.1 621f08d725 DECISIONS.md, RUST.md: Iris decided iris over Masonry, 2026-09-05
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 20:25:43 -04:00
irisandClaude Fable 5.1 e49d0e606f RUST.md, DECISIONS.md, IRIS.md: iris's host-GPU frame time, 2026-09-05
Takes the -gpu host pair the earlier software-mode comparison flagged as
missing. Under real GPU rendering (--features force-gles: the default
Vulkan backend has no adapter at all under plain host-GPU boot, confirmed
by the exact wgpu error), iris's median frame (15.0ms) is faster than
Compose's (20.0ms) on the same session content -- the opposite shape from
the software-mode table. The new redraw-to-submit/submit-to-present split
shows iris's own CPU work is a median 0.2ms per frame; almost the whole
frame is time handing off to the driver, consistent with (but not proof
of) the software-mode gap being mostly SwiftShader's CPU rasterisation
cost rather than iris-specific slowness.

A same-mode software force-gles run, meant to isolate the backend, hit a
third distinct crash instead (SwiftShader's GL path reports itself as
OpenGL ES 3.0, which has no compute shaders, and iris's device request
assumes them unconditionally) -- real scope to fix, not done here, so the
software-mode question stays open. A real intermittent touch-scroll
dropout was also reproduced (six consecutive swipes produced zero
redraws while taps kept working; an identical retry then succeeded) and
is not explained. The idle-redraw and virtualised-culling findings from
the software-mode pass were confirmed to hold under real GPU rendering
too.

DECISIONS.md's DEFERRED item carries the updated table; the iris-vs-
Masonry choice itself is still Iris's to make. IRIS.md records the
FrameReport::record_split/FrameStats::cpu_p50/gpu_wait_p50 API from the
prior commit (e2a1fad), which this pass's measurement used.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 20:07:20 -04:00
irisandClaude Fable 5.1 e2a1fadbec iris: FrameReport CPU/GPU split, force-gles backend switch, iris-scroll.sh rig
Splits each frame sample at queue.submit into redraw-to-submit (iris's own
CPU work) and submit-to-after-present (driver/GPU wait), so RUST.md's I5
"where does iris's frame time go" question can be answered with a number
per half instead of a single total. Adds a force-gles Cargo feature that
switches the Android wgpu::Instance from Backends::PRIMARY to Backends::GL
at compile time (no runtime env-var path exists into an already-launched
Android process on this machine), for isolating SwiftShader-Vulkan vs.
GLES/virgl as the software-mode gap's cause. app/iris-scroll.sh extracts
transcript-bench.sh's exact 24-swipe/6-cycle gesture loop for iris's own
demo app, which transcript-bench.sh cannot drive directly since it opens a
session through the Compose app's own UI.

Verification (this pass, on a disk-pressure-limited host running low on
space): cargo fmt --all clean, no diff. cargo clippy --workspace
--all-targets: no warnings from this diff (pre-existing future-incompat
notices from wgpu/winit/naga only). cargo test --workspace and cargo ndk
for iris-android-app --features transcript-screen were verified clean by
the previous pass on this identical diff (fmt/clippy/test/ndk all clean,
per that pass's own report); not re-run here because the host's disk was
93% full and a concurrent ai-server rebuild (stable toolchain moved to
1.98.1, rebuilding aws-lc-sys from scratch) had driven I/O pressure to
~60%, so a repeat cargo test --workspace sat 50+ minutes doing no useful
work and was stopped rather than left to make the disk situation worse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 18:47:01 -04:00
irisandClaude Fable 5.1 0e4629361b docs: I5's clean scroll comparison between Compose and iris, one session
Same sandbox session content, same emulator, EMU_GPU=software: Compose
(debug, in-app report) 1102 frames/99.0% late/p50 33.8ms/p99 79.5ms vs
iris (release -- debug SIGSEGVs on this emulator) FrameReport 299
frames/94.65% janky/p50 79.1ms/p99 117.8ms (repeat: 233/94.42%/p50
109.3ms). Ticks I5 [x]; states plainly what's not comparable (build
profile forced asymmetric, three different jank definitions, both are
software-rasterised emulator numbers). The two "zero frames" attempts
that preceded the clean runs traced to this session's own script bug
(a cd into /tmp changed which emulator ui-trace targeted), not a
reproduction of the previously-suspected touch-delivery dropout; a
sampler ran the whole session and saw load rise during the gesture
without correlating to any failure. DECISIONS.md's DEFERRED item gets
the same table so Iris can decide iris-vs-Masonry from it -- that
choice is left to her.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 15:21:45 -04:00
irisandClaude Fable 5.1 1e7b1cddb7 RUST.md, IRIS.md, IRIS_TODO.md, DECISIONS.md: record I5's frame report and holddrag results
FrameReport gave a real, measured on-device number (frames=34,
janky%=61.76, p50=26.5ms p90=48.0ms p99=98.1ms worst=98.1ms) and
long-press-then-drag-to-select is now confirmed on-device (logcat plus a
screenshot of the highlighted selection). Neither closes I5's box to [x]
yet: the frame number is real but not the clean single 24-swipe loop
comparable to Compose's, because gestures against this checkout's
EMU_GPU=software emulator intermittently delivered zero touch input this
session -- a new, separately named finding (candidate cause: the
emulator's own software rasterisation measured at ~78% of a CPU core
continuously), not yet root-caused. DECISIONS.md's DEFERRED item is
updated with these numbers rather than a decision made here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 15:00:29 -04:00
irisandClaude Fable 5.1 470f8e5019 transcript-ui: log selection begin/extend, for on-device verification
Selection has no accessibility label of its own yet, so a logcat line at
begin/extend is the smallest way to confirm a real long-press-then-drag
reached DragArbiter/Selection on-device. Driven with the new ui-trace
holddrag action against iris-android-app's transcript screen: produced
"iris selection: begin at row ..." then a sequence of "... extend to row
..." lines, and a screenshot right after shows the expected highlighted
selection spanning multiple rows.

New `log = "0.4.28"` dependency (matching iris-android-app's own pin) --
transcript-ui had no logging facility before this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 15:00:18 -04:00
irisandClaude Fable 5.1 7ae53ad797 iris: FrameReport, a per-frame wall-time report of iris's own render path
dumpsys gfxinfo cannot see a SurfaceView's own GPU-drawn frames at all
(RUST.md's I5 box), so iris needs its own equivalent of Compose's
render-report button before item 3 of the recommendation can be decided
by a number. FrameReport (iris/core/src/render/frame_report.rs) records
each frame's wall time -- from render()'s redraw start to after
queue.submit + present() -- into a fixed 4096-entry ring, and reports
total frames, janky % (>16.7ms, gfxinfo's own budget), P50/P90/P99 and
the worst. Wired into AndroidUiState and android/view.rs's render(), and
exposed as two named controls ("Frame report", "Reset frame report") on
iris-android-app's transcript screen, logged under the crate's fixed tag
so a script can grep "iris frame report" the way transcript-bench.sh
greps "ai-app render report".

6 new unit tests for the ring/percentile math. cargo fmt/clippy/test
--workspace clean; cargo ndk (iris, transcript-ui, and
iris-android-app --features transcript-screen) all clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:23:51 -04:00
irisandClaude Fable 5.1 d17040b601 RUST.md, IRIS.md, IRIS_TODO.md, DECISIONS.md: record I5's Android integration and measurements
I5's transcript screen now runs on-device against a real ai-server on
iris-android-app's new transcript-screen feature (extends I2's shell
rather than a third one), with real scrolling, real touch-drag panning
and tap-by-name accessibility all confirmed by screenshot/log evidence.
I4's own emulator-side check (tap-by-name on the tabs demo) closed the
same session, so its box ticks [x] now.

Still [~], not [x]: the render-time number RUST.md's recommendation
wants for iris couldn't be produced this pass, for a precise and
recorded reason rather than a vague one -- dumpsys gfxinfo cannot see a
SurfaceView's own GPU-drawn frames at all (0 frames reported across a
gesture loop that visibly scrolled), and a SurfaceFlinger --latency
fallback gave no per-frame history either on this Android version. The
Compose side of the same loop did produce a real number under identical
conditions (8.96% janky, 99th percentile 150ms), so this is now a
one-sided number rather than a missing one on both sides.

Also found and recorded: the AVD's saved snapshot carries a GPU config
across restarts, so switching between the documented Vulkan boot
recipes needs a cold boot (clearing snapshots/) that the emu wrapper
does not force -- cost three different-looking crashes before the
pattern was the snapshot, not the code.

DECISIONS.md's DEFERRED item is updated with the numbers Iris needs to
weigh the iris-vs-Masonry call; the call itself stays hers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:09:04 -04:00
irisandClaude Fable 5.1 bf5087a598 iris/android: fix background-thread redraw crash, add missing INTERNET permission
Two real bugs found bringing up I5's Android transcript client, neither
specific to that screen -- any future caller of Tasks::redraw_handle()
from a background thread would hit the first one.

AndroidRedrawHandle::request_redraw called View::post_frame_callback from
a tokio worker thread; its Java side calls Choreographer.getInstance(),
which throws IllegalStateException unless the *calling* thread already
has a Looper, and a JNI-attached background thread has none. That crashed
the whole process (SIGABRT, unwrap() on a JavaException) the first time a
background fetch asked for a second frame. Fixed by routing through
View::post_delayed(0) instead, Android's own thread-safe way to queue
work onto a View's UI thread, landing on a new
IrisViewPeer::delayed_callback override that drains tasks and renders --
same body as do_frame, now running safely on the UI thread.

iris-android-app's manifest never needed INTERNET before (the tabs demo
makes no network call); its absence read as EPERM ("Operation not
permitted") from UreqTransport::new's connect, not the
ECONNREFUSED/ENETUNREACH a dead server would give.

Full account in RUST.md's I5 box and IRIS.md's Tasks::redraw_handle entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:08:40 -04:00
irisandClaude Fable 5.1 aa3d11471f iris-android-app: transcript-screen feature -- I5's Android integration
Extends the existing tabs demo shell (I2/E5's Gradle project, JNI
registration) with a second, mutually-exclusive AndroidAppState rather than
building a third shell -- it already has the working IrisView/MainActivity
Java and the register_view_class wiring, and the only thing a transcript
screen needs on top is a different Client type (the same axis
tabs_ui::build vs. transcript_ui::build already varies along on winit).

`--no-default-features --features transcript-screen` builds
transcript_client::TranscriptClient instead of the plain tabs Client:
fetches the sandbox's session list, opens the first one, and follows it
live, reusing desktop-app's app.rs shape (fold_event/group_tool_runs/
fold_page/raw_seq, a generation counter) almost verbatim. The one real
difference is the redraw path -- android-view has no winit::EventLoopProxy,
so Tasks gained redraw_handle() (iris/src/task.rs) to let a caller request
a frame after each TaskCtx::update from inside a still-running task, not
just once when the whole future completes.

Deliberate simplification, not a template: there is no session list or
enrollment UI here. build.rs bakes the sandbox's host/port/token plus the
pinned CA in at build time from AI_APP_TRANSCRIPT_HOST/_PORT/_TOKEN and
AI_APP_CA, the same trust-boundary reasoning as the Compose app's
GeneratePinnedCert Gradle task, extended to also bake the enrollment since
building a real one (Keystore-sealed storage, a QR/link scanner) is E3's
scope, not this box's. Recorded in RUST.md's I5 box.

tabs-ui and the transcript-screen deps are now both optional, gated by
mutually exclusive tabs-screen (default) / transcript-screen features --
building one screen with the other's default deps still active tripped
Cargo's unused_dependencies lint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:26:10 -04:00
irisandClaude Fable 5.1 78aff64844 client-core: hoist transcript_fold::{fold_page,raw_seq} out of desktop-app
Both desktop-app's app.rs and the new Android transcript client (RUST.md's
I5) need the same page-fold and live-stream resume-cursor logic; per
CODE_RULES's "write the logic once" it now lives in client-core alongside
fold_event/group_tool_runs instead of being duplicated. desktop-app calls
the shared functions; its own copies and their tests moved with them.

Also fixes a clippy::collapsible_if in config.rs's percent_decode, found
while re-running clippy after this change (let-chains are stable now).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:25:46 -04:00
irisandClaude Fable 5.1 9a33cb5384 docs/: move the design and working documents out of the repo root (CLAUDE.md and AGENTS.md stay, harnesses read them there)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:03:42 -04:00
iris 45ced405f3 Merge branch 'worktree-agent-afe80868604fef704' into tmp-merge 2026-09-05 12:59:54 -04:00
iris 62199aa3a7 Merge remote-tracking branch 'origin/rustify' into worktree-agent-afe80868604fef704
# Conflicts:
#	IRIS.md
#	RUST.md
2026-09-05 12:59:39 -04:00
irisandClaude Fable 5.1 b133d85943 RUST.md, IRIS.md, CLIENT_CORE.md: record E4 done
RUST.md: E4 ticked with the screenshot path, the exact commands against
app/ui-sandbox.sh, and the streaming-duplication bug the screenshot found;
"Where things stand" moved E4 out of "in flight" into its own done bullet.
IRIS.md: transcript_ui::build_tree, the public API change transcript-ui
gained for this. CLIENT_CORE.md: client_core::config's table row and its
correspondence note.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 12:55:59 -04:00
irisandClaude Fable 5.1 ba6817fee5 iris: run-headless.sh --bin, for screenshotting a real binary not just an example
desktop-app (RUST.md's E4) is a real crate binary a person runs, not a
demo under examples/, and it needs its own argv (--ca, --link) to start
at all -- neither of which the script had a way to express. --bin swaps
`cargo build --example`/`target/debug/examples/NAME` for the `--bin`
equivalents; $RUN_HEADLESS_ARGS is word-split into the launched binary's
own argv, since no example ever needed one before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 12:55:52 -04:00
irisandClaude Fable 5.1 73ee63bc1b iris: desktop-app, a winit window for the transcript screen (RUST.md's E4)
The pass condition was the same screen, from the same crate, running in a
window with only the layout differing. desktop-app is a new workspace
member: a session list (plain iris::widget::Span, rebuilt on selection)
beside transcript_ui::build_tree's screen, talking to a real ai-server
through client-core's ApiClient/UreqTransport/follow_session_events, with
background network I/O on plain std::threads reporting back through
winit's EventLoopProxy rather than iris's Tasks (which only redraws once
per async closure, not once per SSE event).

Both pass-condition proofs held against app/ui-sandbox.sh's real server:
the list showed a spawned session, selecting it loaded its transcript, and
a message sent from the composer streamed its reply back live. Along the
way, a real bug: resuming the SSE stream from a folded item's seq (which
for a still-open assistant message is its *first* delta's seq by design)
replayed already-folded deltas and duplicated the tail of the reply --
found by a run-headless.sh screenshot, fixed by resuming from the raw wire
seq instead, and covered by a regression test.

Deliberately simple and said so in app.rs's module doc: every SSE event
refolds the whole transcript and rebuilds the right-hand tree from
scratch rather than reaching for TranscriptScreen::push_row's incremental
append, since a streaming reply is a row whose text keeps changing after
it appears and push_row can only add a new one. Fine at a desktop
session's scale; the real fix needs transcript-ui to expose updating a
row in place. Android is untouched by this step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 12:55:41 -04:00
irisandClaude Fable 5.1 8f0aec449a transcript-ui: build_tree, the screen without claiming the window root (RUST.md's E4)
build() always finished by calling ui_state.set_root(), which is right for
a window that *is* the transcript screen and wrong for a caller embedding
it beside something else (the desktop app's session list). build_tree()
is build() minus that last step, returning the widget tree instead of
planting it; build() is now one line on top of it, so nothing else
changes for existing callers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 12:55:29 -04:00
irisandClaude Fable 5.1 6d5fd64bb0 client-core: EnrolledServer, the aiapp:// enrol-link parser (RUST.md's E4)
A Rust client needs the same host/port/token an Android phone gets from
scanning an aiapp://enroll?... QR, so a desktop build can enrol from the
identical text pasted rather than a second format invented for it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 12:55:22 -04:00
irisandClaude Fable 5.1 e5880c33f4 iris: DragArbiter closes I5's touch-drag pan-vs-select gap
A row's own click_or_drag() selection handler always won the same
gesture a list-level pan wanted, since run_sensors gives the inner
layer first refusal every frame it's pressed. DragArbiter
(iris/src/sense.rs) decides pan vs. select the way Android does:
vertical drag pans immediately, a held stationary press starts a
selection after LONG_PRESS, and a horizontal drag on already-selected
text extends immediately. transcript-ui's Selection::drag routes
every row's drag through one arbiter per list, driving List::scroll
for a pan instead of a second scroll mechanism.

8 new unit tests (iris::sense::drag_arbiter_tests); cargo
fmt/clippy/test --workspace and cargo ndk (iris, transcript-ui) all
clean; run-headless.sh screenshot byte-identical to before the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 12:26:54 -04:00
irisandClaude Fable 5.1 a853eb5a4d DECISIONS.md: the summary file for choices made without Iris; RUST.md: note the two in-flight pieces
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 12:18:26 -04:00
iris 22d5c6585a Merge branch 'worktree-agent-a23aa965694b9eaf5' into rustify (I5: transcript-ui, SpanStyle) 2026-09-05 07:56:09 -04:00
irisandClaude Sonnet b063fbd7f9 RUST.md, IRIS.md, IRIS_TODO.md: record I5 -- transcript-ui built and
partial, the recommendation's numbers still missing

I5's own box: the seven "hard to get back" behaviours each shown or
given a sourced reason, the exact verification commands and results,
and what remains (Android integration, touch-drag-vs-selection
arbitration, row accessibility names, a tappable link, code-span chip,
selection's anchor-row shortcut, code-fence syntax highlighting) --
each also a dated IRIS_TODO.md item so it is not silently dropped.
Ticked [~] rather than [x]: the widget-tree half is built and tested,
the emulator half is not.

"Where things stand" and the Recommendation's item 3 updated in place
to say plainly that neither Masonry (E2) nor iris (I5) has produced a
render number yet, and why -- not a bad measurement, no measurement
obtainable yet on either side -- with the structural findings that do
exist (iris now does cross-row selection and per-span inline rich text,
neither of which exists in masonry/masonry_core/xilem today) recorded
as what currently favours iris absent a number.

IRIS.md gets SpanStyle's own entry: what changed, why, and the one
thing a future TextBuilderOutput impl must remember (both TextOutput
and TextEditOutput apply .spans() -- this box shipped the bug of
missing one of the pair once already).

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 07:55:04 -04:00
irisandClaude Sonnet 3f25e7ebca iris: transcript-ui, the transcript screen (RUST.md's I5)
A new workspace member, iris/transcript-ui/, built the same way
tabs-ui is: generic over Rsc: HasEvents + Rsc::State: FocusHost, on
client-core/event-model by path (real code, matching E2's precedent).
Four modules:

- markdown.rs: CommonMark (pulldown-cmark) -> one plain string plus a
  Vec<SpanStyle>, so a row's headings/bold/italic/inline-code/links
  render inline inside one wrapped TextEdit rather than one widget per
  block -- the actual proof that iris can do what E2 found Masonry
  structurally unable to (masonry/src/widgets/text_area.rs's
  "TODO: RichTextInput").
- row.rs: one iris::widget::List row per folded TranscriptRow. A
  TranscriptRow::Tools group collapses to a summary and expands to
  every call's own tool/input/output on tap, using List::extent +
  note_tap for hold-the-edge exactly as list.rs's module doc describes.
- selection.rs: cross-row selection -- a drag that starts in one row's
  TextEdit and crosses into another's, coordinating each visible row's
  own select/select_all/deselect from one pointer gesture. The one
  Masonry's own text_area.rs cites as impossible (no
  SelectionContainer-shaped type anywhere in masonry/masonry_core/
  xilem).
- composer.rs: a growing multi-line composer with no fixed height,
  wired beside the list with .height(rest(1)) -- the real screen for
  IRIS_TODO.md's "input box" benchmark case.

9 new tests (5 pure markdown, 4 selection), all passing. Screenshotted
via run-headless.sh: real inline rich text visible (bold, italic,
inline code, a bigger bold heading, a coloured link, a monospaced
fenced block, a collapsed tool-call row).

What this box does not close, each recorded at its own point (RUST.md's
I5 box, IRIS_TODO.md's dated entries): no Android integration exists
yet for this screen (no cdylib/Gradle shell the way iris-android-app
wraps tabs-ui), so the emulator-side render-number pass condition was
not attempted; a touch-drag pan over a row's own text currently loses
gesture arbitration to that row's own drag-select (diagnosed and named,
not silently broken); row-level accessibility names, a tappable link, a
code-span background chip, and Selection's anchor-row shortcut are
scoped shortcuts recorded in place.

cargo fmt/build/clippy/test --workspace clean; cargo ndk -t x86_64
-P 26 build/clippy clean for both transcript-ui and iris.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 07:54:51 -04:00
irisandClaude Sonnet 0af4c88d08 iris: SpanStyle, per-range text styling (RUST.md's I5)
A TextBuffer used to have exactly one style for its whole string,
applied via parley's push_default. SpanStyle adds a second, optional
layer -- a byte range plus whichever of colour/family/font size/
bold/italic/underline it overrides, pushed with parley's own
push(property, range) -- so a heading, bold, inline code and a link can
each carry their own look inside one wrapped, selectable TextEdit. This
is the actual answer to RUST.md's E2 finding against Masonry
(TextArea::edit_styles() is one StyleSet for the whole editor).

PlacedGlyph gains a color field, read from parley's own per-run
Style::brush, and Painter::glyphs draws each glyph in its own colour
instead of one colour for the whole RenderedText.

Real bug found while wiring this into a live screen (not caught by any
test, since markdown's own tests only check string/range logic): spans
were threaded through TextOutput::run but not the sibling
TextEditOutput::run, so every editable field silently dropped them.
Fixed in build.rs; see IRIS.md's entry for why both call sites are a
pair to keep in sync.

cargo fmt/build/clippy/test --workspace and cargo ndk (iris,
iris-android excluded per its own workspace exclusion) all clean; 28
existing iris tests unaffected.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 07:54:28 -04:00
irisandClaude Sonnet ceabd00805 E5: package app/shellApp without Gradle (cargo xtask apk)
New xtask/ crate (no deps) runs cargo ndk -> javac -> d8 -> aapt2 ->
zipalign -> apksigner directly, signed with the same key build-apk.sh
uses. Both pass conditions proved on the ai-app-2 emulator: the xtask
APK installs over the Gradle-built shellApp, and the notification
service starts and posts a real notification while backgrounded.

Adds one printRuntimeClasspathJars task to shellApp/build.gradle.kts
(and a matching signingConfig) -- the one disclosed Gradle call the
xtask still makes, to resolve the AndroidX/​:link dependency graph.
That call's Kotlin compilation of :link as a side effect also answers
E3's open kotlinc question, so no Java port of ServerStore was needed.
Wires a second Apk component into .dev-updater.ron beside the existing
one. Full writeup in RUST.md's E5 box.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 07:11:44 -04:00
iris 32a5256a0d Merge branch 'worktree-agent-a466c08a4014dbcbe' into rustify (I4: AccessKit names) 2026-09-05 07:06:29 -04:00
irisandClaude Sonnet 4cfe0ef6e6 iris: I4 -- accessibility names via AccessKit
Builds one flat AccessKit tree (iris_core::ui::access::AccessTree) from
iris's own widget tree: a synthetic Role::Window root with every named
widget as a direct child, names from the existing `.label()`, roles from
a new Widget::access_role() (default Unknown, TextEdit overrides to
TextInput/MultilineTextInput), bounds from UiRenderState::window_region
so a moved subtree reports where it actually is. Modular the way input's
sense registry is: Widgets gained one HashSet<WidgetId> ("named"),
populated only by .label()/set_label and drained by free_next (the
existing removal path), and AccessTree walks only that set -- a widget
nobody named costs it nothing. Updates only when the named set's name,
role or bounds actually changed, with a rebuild counter mirroring
take_counters (confirmed 1/0/1 across first-draw/unchanged/moved in
access_tests.rs).

Pushed through accesskit_winit on the desktop (DefaultApp::new now
creates the window hidden, builds the adapter, then shows it, per that
constructor's requirement) and accesskit_android on Android
(IrisViewPeer now implements AccessibilityNodeProvider). Both action
handlers are inert on purpose: AGENTS.md's tap-by-name is a real touch
at the node's bounds, not an AccessKit action request, so the ordinary
pointer path already answers it once bounds are right. E1's
detach-abort mitigation is carried into android/access.rs's
raise_if_enabled, which gates every QueuedEvents::raise on
AccessibilityManager.isEnabled().

tabs-ui's five switch buttons now carry .label()s matching their
on-screen text, giving both the desktop run and the emulator step real
names to find.

Verified on host: cargo fmt/build/clippy/test all clean (28 tests, 3
new), cargo ndk build+clippy clean for iris and iris-android-app,
run-headless.sh tabs --shot byte-identical to I2's prior screenshot
(27266 bytes). Not run: the emulator step (ui-trace tap-by-name against
iris-android-app), held by another session this pass -- exact commands
recorded in RUST.md's I4 box.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 07:05:41 -04:00
irisandClaude Sonnet c9b273ff16 E3: the Kotlin/Java shell over a JNI bridge into Rust (RUST.md)
Two Java classes (MainActivity, NotificationService) hand their lifecycle
to a new android-shell crate built on client-core; client-core gains
notifications.rs (the /notifications SSE parse and attention_line, ported
from Notifications.kt). Packaged as a new app/shellApp Gradle module
rather than a rewrite of app/androidApp in place, so that module's working
Compose UI is untouched.

Both pass conditions held on the emulator: a notification arrived in
Android's drawer with the app closed, and a shared text share landed as a
real message in a sandbox session's transcript. Found and fixed three
real bugs along the way (a silently-wrong JNI signature from a generic
JObject parameter, a class-by-name lookup failing on this crate's own
background thread for lack of an app ClassLoader, and onStartCommand
opening two /notifications connections per enrollment -- the last a
latent bug in Notifications.kt itself). Full account, exact commands and
what was deliberately cut are in RUST.md's E3 box.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 06:47:32 -04:00
iris 8adda94a7a Merge branch 'worktree-agent-a33c31aef1fd6d868' into rustify (I3: iris::widget::List) 2026-09-05 06:29:08 -04:00
irisandClaude Sonnet 3a9208f38b RUST.md, IRIS.md: record I3 -- List built and benchmarked, emulator step named
Ticks I3's box with the numbers (all flat across N as required),
updates "Where things stand", and adds IRIS.md's public-API entry for
List plus the fill-shaped-background lesson. The remaining emulator
comparison against transcript-bench.sh needs List wired into an actual
transcript/session screen (closer to I5's scope than I3's), so it's
recorded as the next step with the exact command rather than left
silently undone.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 06:27:58 -04:00
irisandClaude Sonnet e898370bf4 iris: fix List placing a fill-shaped background at its oversized measurement size
Building the I3 example (800 rows, some with images, styled with
.background(rect(tint))) surfaced a real bug: place()'s Bottom-known
branch measured a row at an oversized, fixed-size region and moved it
into its final box with reposition -- a pure translation. That is
correct for wrapped text, whose reported height doesn't depend on the
height it was offered, but Rect (used for every row's background) is
is_size_independent because it fills *whatever region it is given*,
so it painted at the oversized size and reposition never shrank it
back down. The screenshot showed one oversized tinted rectangle
covering the whole visible window instead of per-row backgrounds.

Fixed by caching each row's height once measured and placing an
already-measured row directly at its exact box (one widget_within/
reposition pass, same as any known-size placement) instead of
re-measuring every frame. A first-ever appearance still pays a
two-draw measurement (draw_twice), and a row whose real height
changed since it was cached is corrected the same frame it redraws
(not a one-frame lag) via an explicit reposition when the two
disagree. Steady-state scroll cost is unaffected: an unchanged row's
single placement call still hits draw_inner's existing skip-or-move
fast path.

Also fixes repair_anchor unconditionally re-snapping a bottom-anchored
list's offset to the viewport's edge on every frame snap_end was true
-- which discarded a live scroll() call the moment it ran, since
snap_end is only recomputed at the end of a layout pass and so still
read true from before the scroll. Now only re-snaps when the viewport
itself actually resized (tracked via last_viewport_len).

Added a_fill_shaped_background_is_not_left_oversized, a direct
regression test for the background bug (checks the background rect's
own painted pixel size, not just the row's reported extent, which was
already correct). cargo test -p iris (26 passed), clippy --all-targets
and --benches --release, fmt --all -- --check all clean. Rebenched:
all five scenarios still flat across N = 100/1,000/10,000 (numbers in
RUST.md's I3 box). Visually verified via
run-headless.sh message_list --shot, cropped with a throwaway PNG
decoder since no image tooling is installed here.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 06:26:02 -04:00
irisandClaude Sonnet 6e0bd06e4d RUST.md: E2 -- a transcript in Masonry on android-view, and the touch-scroll gap it found
Built a real transcript screen (e2-transcript, beside E1's demo in
~/src/android-view) against a live app/ui-sandbox.sh session through
client-core: real fold, real ApiClient, VirtualScroll<dyn Widget> over 854
events, block-level markdown via pulldown-cmark into Prose, and a tool row
that holds its top edge on expand via overwrite_anchor.

The headline result is negative and load-bearing: neither VirtualScroll nor
Portal reacts to a touch drag, only to wheel-style PointerEvent::Scroll
(virtual_scroll.rs:504-523, portal.rs:259-267), confirmed both by reading
and empirically (a real swipe and a synthetic Android scroll event both
moved nothing). That blocks transcript-bench.sh's own gesture, so the
render-number half of E2's pass condition has no comparison to make yet.
Selection across rows and per-span rich text are also confirmed impossible
on the pinned xilem commit, each cited to its source. Full writeup, repro
commands and screenshots list in E2's own box.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 06:03:42 -04:00
irisandClaude Sonnet 03da47e550 iris: benches/message_list.rs measures the real List, adds insert-above and expand-hold
The (a)/(b)/(c) scenarios built their own Span+Scroll pair, so they
never exercised the virtualised widget the transcript screen actually
needs. Rewritten on top of iris::widget::List, plus two new scenarios
from RUST.md's I3: (d) insert-above-anchor (paging older history onto
an already-scrolled list) and (e) expand-a-row-holding-its-edge
(list.rs's note_tap mechanism). Both come out flat across N =
100/1,000/10,000, as required.

Also fixes a real inefficiency this rewrite surfaced: List::place's
"generous" measurement bound was derived from viewport_len, so a
sibling resizing the list itself (the (c) scenario) changed that
bound every tick and defeated draw_inner's same-size fast path,
forcing a full redraw of every visible row instead of a move. It is
now a fixed module constant (GENEROUS_PADDING), independent of the
list's own size -- draws for (c) dropped from 3059 to 684 over 40
ticks.

cargo test -p iris (5 List tests still pass), cargo clippy
--all-targets and --benches --release, cargo fmt --all -- --check all
clean. Numbers recorded in RUST.md's I3 box.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 05:42:54 -04:00
irisandClaude Sonnet a2cd119985 iris: add List, a virtualised bottom-anchored list (RUST.md I3, part 1)
Variable-height rows, keyed by a u64, composed only while visible via
the existing draw_inner old-children diff (LAYOUT.md), moved not
re-laid-out on scroll (Painter::widget_within/reposition, an O(1)
offset write), a scroll anchor named by slot index so a row inserted
above costs one index increment rather than a content-offset
recompute, "more" sentinels as two ordinary optional widgets, and
"hold the edge nearest the tap" resolved in the layout pass before any
primitive is written for the frame.

cargo test -p iris (24 passed, 5 new), cargo clippy --all-targets and
cargo fmt --all -- --check clean.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 05:35:12 -04:00
irisandClaude Sonnet 19c36e37f2 iris: give masks/move_offsets their own bind group, fixing O(N) image append
GpuTextures folded the masks and move_offsets storage buffers into every
standalone image's own bind group (group 2), alongside that image's
texture view. Since ArrBuf::update hands back a new Buffer identity
whenever either buffer's length changes -- which a widget getting its
first move-offset slot can trigger, unrelated to any image -- every
live image's bind group had to be rebuilt whenever either buffer grew.
Appending a 1,001st image to 1,000 already-settled ones cost 1,001
bind-group creates, not 1 (IRIS_TODO.md, run-bench.sh images).

Moved both buffers into their own bind group (group 3 in shader.wgsl
and UiRenderNode), bound once per frame in draw() rather than once per
per-image bind group. GpuTextures's image bind groups now only
reference the atlas array view, the image's own view and the sampler --
none of which change when masks/move_offsets resize -- so a resize
touches exactly one bind group regardless of how many images are live.
This also closes the "two frames to reach steady state" item, which was
the same bug measured a second way.

Verified: cargo build/clippy/test clean (19 tests), cargo ndk build/clippy
clean, run-headless.sh tabs --shot byte-identical (27266 bytes). New
run-bench.sh images numbers: cold load unchanged at 1000/0/0/0, append
now 1 instead of 1001. Both Fix items in IRIS_TODO.md ticked with the
before/after numbers.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 05:28:38 -04:00
irisandClaude Sonnet e2873df92e iris: fix I2's render gap -- window uniform never left (0, 0) on android-view
UiRenderNode::new seeded the GPU window uniform from
WindowUniform::default() rather than the surface's real size, so
shader.wgsl's vertex stage divided every primitive's position by
(0, 0) and produced NaN/Inf clip coordinates on both Vulkan and GLES.
winit's backend never hit this because winit fires an initial
WindowEvent::Resized that corrects the uniform before the first frame;
android-view has no equivalent event, so the node it built never got
corrected. Seed the uniform from the SurfaceConfiguration passed to
UiRenderNode::new instead, which is already right on both backends at
construction time.

Verified on the ai-app-2 emulator (Vulkan/SwiftShader and, temporarily
forced, GLES/virgl): the tabs example now draws its widgets instead of
just the clear colour. Ticks I2 in RUST.md.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 05:19:55 -04:00
iris ea13889a21 Merge branch 'worktree-agent-a23e63cec86723942' into rustify 2026-09-05 05:09:20 -04:00
irisandClaude Sonnet 6317685d1a iris: android-app's Gradle shell, and the emulator run for I2
The Gradle side of RUST.md's I2: MainActivity, IrisView (extending
android-view's RustView with the two native methods it has no hook
for -- window insets, and unregistering this view's entry in
iris::android::insets's side table), and RustView.java/
RustInputConnection.java vendored from android-view (no published AAR
to depend on) with one deliberate diff noted in a comment: mViewPeer
is protected rather than package-private, so a subclass in a different
package can reach it.

Measured on the emulator (x86_64, API 26, SwiftShader Vulkan):
dumpsys input_method shows the served InputConnection is ours, and
Gboard's suggestion strip reads real buffer content back through
text_before_cursor ("hi | Hi | HI" after typing "hi") -- the same bar
E1 set, met. Not met: nothing draws. The clear colour reaches the
screen (confirmed by swapping it to magenta) and the layout engine
reports the correct widget count and pixel regions (log::debug! calls
left in view.rs's render() for exactly this), but no primitive shows
up, on both Vulkan/SwiftShader and GLES/virgl. Root cause not found;
one unconfirmed lead (a GLES-only D2/D2Array warning that could point
at the glyph atlas) is written up in RUST.md's I2 rather than chased
into core/src/render/, which is mid-flight in a separate benchmark
branch this session.

I2 is therefore built and wired but not tickable -- RUST.md has the
full writeup, what was ruled out, and where to pick this up.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 05:08:25 -04:00
irisandClaude Sonnet f79bd7ca71 iris: add the android-app cdylib crate (I2, part 3)
iris/android-app is the concrete app RUST.md's I2 is judged against:
JNI_OnLoad, a Client implementing AndroidAppState, and new_view_peer
wrapping iris::android::new_peer's generic function in the plain
function pointer register_view_class needs. Its UI is tabs-ui::build,
unchanged from the winit example.

Deliberately excluded from the iris workspace (iris/Cargo.toml's new
`exclude`): android-view needs the NDK sysroot to link, so folding this
crate in would break `cargo build --workspace --all-targets` on the
host. It resolves as its own single-crate workspace instead, built
with `cd iris/android-app && cargo ndk -t x86_64 -P 26 build`.

Verified: cross-compiles and clippys clean for x86_64-linux-android
API 26; the host iris workspace (build/clippy/fmt/19 tests) is
unaffected. Not yet built: the Gradle shell (IrisView.java,
MainActivity, AndroidManifest) to actually install and run this on the
emulator -- next in RUST.md's I2.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 04:46:10 -04:00
irisandClaude Sonnet 9c935f8ce8 iris: factor the tabs example's widget tree into tabs-ui (I2, part 2)
The pass condition for android-view backend (RUST.md's I2) is that the
tabs example itself, text field included, runs there -- not a second
demo with the same shape. tabs-ui/src/lib.rs is that widget tree moved
out of examples/tabs/main.rs into a small crate generic over `Rsc:
HasEvents` and `Rsc::State: FocusHost`, so the winit example and the
upcoming android-app cdylib both call the same `build()` rather than
carrying two copies. Nothing in it names either backend.

Verified: the winit tabs example still renders pixel-identically via
run-headless.sh (27266 bytes, unchanged), tabs-ui cross-compiles clean
for x86_64-linux-android alongside iris, and host build/clippy/fmt/
tests are unaffected.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 04:42:13 -04:00
irisandClaude Sonnet 982449293d iris: add an android-view backend beside winit (I2, part 1)
Relocates the platform-neutral halves of the winit `default` backend
(WidgetState, CursorState/sense, Tasks, Selector/Selectable's focus
handling) into shared crate-root modules so both backends can use them
without duplication, generalizes Tasks' redraw nudge behind a
RequestRedraw trait instead of a concrete winit::window::Window, and
adds iris/src/android/: a second backend on android-view's ViewPeer --
wgpu on the view's surface, touch as a mouse-like cursor, an
InputConnection bridge onto TextEdit (I1's parley editor), and a
window-insets side channel since android-view has no hook for it.
winit's own Android support pulls in android-activity without a
selected backend feature, so `default`/`android` are now target-gated
rather than both compiled in; confirmed by cross-compiling before this
split (cargo ndk failed inside android-activity) and after (clean).

Host build/clippy/fmt/tests and the android (x86_64, API 26)
cross-compile of the iris crate are all clean; the winit tabs example
still renders via run-headless.sh. Not yet exercised: an actual
android-app crate and Gradle shell to run this on the emulator -- next
in RUST.md's I2.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 04:39:06 -04:00
irisandClaude Sonnet 288853c094 iris: on-demand message-list/image benchmarks, and two O(N) findings
IRIS_TODO.md's "Benchmarks" item: a message list of N wrapped-text rows
(first-frame cost), scrolling it, and growing an input box above which
the list must move rather than re-layout -- all as a plain, harness=false
`cargo bench` binary (iris/benches/message_list.rs) since UiRenderState
touches no GPU or window, chosen over criterion because every scenario
here reduces to a count take_counters already answers exactly, and a
new dependency wasn't worth it. Scroll (200 ticks) and the input-grow
case (40 lines) are flat across N=100/1,000/10,000: LAYOUT.md's O(1)
move chain holds.

The many-images case (d) needs a real wgpu device, so it's a headless
example (iris/examples/bench_images.rs) plus a new
GpuTextures/UiRenderNode counter, take_image_bind_group_creates,
mirroring take_counters. It found two real non-O(1) costs, recorded as
new Fix items rather than redesigned: bind-group creation takes two
frames to settle after a cold load instead of one, and appending a
single image to an already-loaded 1,000-image list rebuilds all 1,000
existing bind groups (masks/move_offsets buffer growth triggers
rebuild_image_bind_groups unconditionally).

run-bench.sh wraps both. Numbers and commands are in IRIS_TODO.md.

cargo fmt --all -- --check, cargo clippy --all-targets, and
cargo test --workspace (19 passed) all clean; benches are not run by
cargo test.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 00:13:58 -04:00
irisandClaude Fable 5.1 fba572427d RUST.md: client-core is built; IRIS_TODO.md committed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 23:55:05 -04:00
iris 85ec5416b6 Merge branch 'worktree-agent-a30feda38122e4492' into rustify 2026-09-04 23:54:48 -04:00
irisandClaude Sonnet 643daf5637 iris: route pointer input per kind, so scroll falls through a hovered button
IRIS_TODO.md's "Input does not fall through by input type": run_sensors
treated "the cursor is over this widget" and "this widget consumed the
event" as the same check, so a widget registered only for click() still
blocked a Scroll meant for a list underneath it. Fixed by judging
consumption per input kind -- with nothing momentary happening this
frame the topmost hovered widget still wins (unchanged), but once a
scroll or a press/release is actually happening, only a widget whose
registered senses include a matching non-hover one (via the new
TypeEventManager::registered, which lists a widget's registrations
without running anything) can consume it.

iris/src/sense_tests.rs builds a button-over-a-list Stack with a plain
HasEvents impl (no GPU or window) and checks both directions: a scroll
over the button reaches the list, and a real click still reaches the
button. Confirmed to fail on the pre-fix code and pass after.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 23:52:37 -04:00
irisandClaude Sonnet 8db0184384 TEXTURES.md: exercise grow_array (a second atlas layer opening) on tabs
Reasoned through but never watched happen, per the file's own "Not
separately stress-tested" note. Temporarily dropped PAGE from 1024 to
64 so tabs's ordinary mix of text sizes/families already exceeds one
page; a throwaway eprintln in grow_array confirmed two real grows in
one run (1->2, 2->4 layers), and run-headless.sh showed every tab's
text rendering correctly across layers, with no corruption. Both
temporary changes reverted; tabs and minimal confirmed byte-identical
to the pre-check screenshots afterward.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 23:44:46 -04:00
irisandClaude Sonnet 1a6599e1b2 iris: Widget::draw reports the size it used, replacing desired_width/height
Implements LAYOUT.md end to end: one fn draw(&mut self, &mut Painter) ->
Size replaces draw + desired_width/desired_height on every widget in
iris/src/widget/, SizeCtx and Cache are deleted, and a moved widget
(Scroll, Offset) costs one move_offsets write resolved by a shared
resolve_move WGSL function in both shader stages -- O(1) regardless of
how many primitives are in its subtree, measured at 500 in the new
iris/src/layout_tests.rs (a plain unit test: UiRenderState touches no
GPU or window).

Five real bugs surfaced only by diffing iris/run-headless.sh screenshots
against the pre-change tree and are written up in LAYOUT.md's
"Deviations found during implementation": Aligned's provisional draw
composing painter.region() a second time through widget_within; Sized/
MaxSize reporting a capped size while still painting their child
unconstrained (fine under the old two-pass model, wrong once a parent
like Aligned draws before knowing the final size); a widget's
move_offsets parent link being unreadable from self.active while its
own ActiveData is still mid-construction; Painter::reposition needing
the child's *painted* footprint (its reported size, top-left anchored)
rather than its offered region; and a widget's move slot needing to be
reused in place across redraws, with its delta reset, rather than
reallocated.

All four iris/examples render pixel-identical to the pre-change tree.
cargo fmt/clippy/test clean across the workspace (18 tests: 14
pre-existing plus 4 new).

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 23:40:56 -04:00
irisandClaude Sonnet 0a2f4fa1fe Add CLIENT_CORE.md, and run event-model and client-core in run-tests.sh
CLIENT_CORE.md is the map for the crate: what holds what against the
Kotlin it replaces, how many tests were ported per file (85 total,
test-for-test where the Kotlin had JVM tests), what api.rs and
transcript_fold.rs cover versus don't yet, and the two things left
deliberately undone with reasons (TranscriptUnits.kt's Compose-specific
flatten, and event_model::Event's missing Unknown catch-all).

run-tests.sh now loops event-model, client-core and server rather than
only server, so the new crates' tests run from the same one command
AGENTS.md already points at.

Note for whoever merges this into rustify: this worktree branched
before RUST.md existed there, so I could not apply the requested edit
to its "Where things stand" bullet without an add/add conflict against
concurrent work on that file. Suggested wording is in this commit's
message on the orchestrator side -- apply directly to rustify's
RUST.md: mark item 1 of the Recommendation and the "Not started:
client-core" bullet as done, pointing at client-core/ and
CLIENT_CORE.md, dated 2026-09-04.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 23:02:58 -04:00
irisandClaude Sonnet 237886c11e client-core: port the transcript fold (events into rows)
Ports the non-Compose half of app/.../TranscriptItems.kt (TranscriptItem,
foldEvent, runIdFor, settleReply, placePeerNote, splitRun) and
ToolRows.kt (TranscriptRow, groupToolRuns) into transcript_fold.rs, with
7 tests covering delta accumulation, settling, tool-run grouping, a
ToolEnd with no matching start, and a question attaching to its call's
row versus drawing its own.

Not ported: TranscriptUnits.kt's flatten of a row into bounded Compose
list units (a fact about that UI framework, not the transcript), and
joinPages/healSplitMessage/adoptRun (page-boundary healing) -- both
recorded in CLIENT_CORE.md as left for whoever picks this up next.
Also noted there: event_model::Event has no Unknown catch-all, so an
event type this build doesn't recognise fails to parse rather than
degrading to a placeholder row, unlike Events.kt's hand-kept mirror.

cargo test (85 passed), clippy --all-targets and fmt clean.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 23:01:01 -04:00
irisandClaude Sonnet e8dbcaa7db client-core: SSE framing, REST client and event stream
Adds sse.rs (a pure port of Sse.kt's frame parser), api.rs (a Transport
trait plus a ureq-backed implementation and an ApiClient covering the
session lifecycle: list/read, message/unqueue/answer,
interrupt/stop/start, title/cwd/model/permission-mode/notify,
command/compact, delete, and a transcript page), and event_stream.rs
(follow_session_events, mirroring EventStream.kt's reset/event split).

ureq rather than reqwest: server/ already depends on it for its own
outbound HTTPS, this stays blocking like Api.kt's HttpURLConnection
calls with no async runtime to carry, and its own PEM cert support
means no extra rustls/rustls-pemfile dependency to pin. Network I/O
sits behind Transport so ApiClient and follow_session_events are
tested with fakes, no server involved.

Not yet covered, tracked in CLIENT_CORE.md: setups, the file explorer,
usage, models, and attachments/import.

cargo test (78 passed), clippy --all-targets and fmt clean.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 22:57:50 -04:00
irisandClaude Sonnet 26163b25b2 client-core: port the transcript cache
Ports app/.../TranscriptCache.kt (chunked JSONL directory, suffix/gap
tracking, backwards line reader, damage recovery, eviction) with the
full TranscriptCacheTest suite (18 cases). One correction the port
found in translation: SessionCache::guard's Err branch would have
disabled the whole cache on a single damaged chunk, since a damaged
suffix and a real I/O failure both arrived as Err from the same
closure -- separated so damage discards only the one session, matching
the Kotlin original's separate `catch (e: Damaged)` from
`catch (e: IOException)`.

cargo test (67 passed), clippy --all-targets and fmt clean.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 22:52:32 -04:00
irisandClaude Sonnet 762c1290a1 client-core: port the ANSI parser, syntax highlighter and markdown scanner
Ports app/.../Ansi.kt, Highlighter.kt, Languages.kt and MarkdownSyntax.kt
to client-core, module for module, with every HighlighterTest and
AnsiTest case ported alongside (49 tests total). ansi.rs replaces
Compose's AnnotatedString/SpanStyle with a plain StyledText/Style pair
so the crate stays free of any UI framework, per RUST.md.

cargo test (49 passed), clippy --all-targets and fmt clean.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 22:47:41 -04:00
irisandClaude Sonnet 62dd6b7912 Ignore event-model's and client-core's target/, like server's own
The event-model commit picked up its build directory because there was
no gitignore entry for it -- server/target/ is listed explicitly rather
than a blanket target/, and the new crates need the same line each.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 22:40:36 -04:00
irisandClaude Sonnet bc3db183e3 Extract the event model into its own crate, shared with client-core
RUST.md's recommendation item 1 starts here: Event, QuestionOption,
SessionStatus, ImageRef, AttachmentRef, SeqEvent, context_tokens and
context_after move to a new event-model crate so a future Rust client
shares one definition with server/ instead of Events.kt's hand-kept
mirror. session/driver.rs and session/transcript.rs re-export
everything they used to define, so nothing downstream of either
module changed.

cargo test (127 passed), clippy --all-targets and fmt clean in both
server/ and event-model/.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 22:40:15 -04:00
irisandClaude Sonnet e0a473e090 iris: replace the bindless texture array with an atlas array + per-image bind groups
The old pipeline bound every texture ever drawn (glyph atlas pages and
standalone images alike) in one binding_array<texture_2d<f32>> and asked
every device, unconditionally, for VK_EXT_descriptor_indexing -- which a
real share of Android GPUs lack and which failed outright on the Android
emulator's software Vulkan (see TEXTURES.md's "iris's binding array does
not survive real Android hardware").

Implements TEXTURES.md's "Recommended shape": the glyph atlas is now one
texture_2d_array (a layer per page, grown by doubling + GPU-side
copy_texture_to_texture); a standalone image is its own ordinary Texture
and BindGroup, drawn with its own draw() call from a separate per-layer
instance list; group 2's layout is {atlas array, one image slot, sampler,
masks}. request_device now asks for no features and no binding-array
limits at all, and UiLimits is gone.

Also fixes (by making moot) the changed=false bug the review found, where
a Patch in the same batch could cancel an earlier Push's rebuild signal,
and documents the swap_remove draw-order invariant apply_free already
relied on.

Verified: cargo fmt/build/clippy/test clean in iris/ on the pinned
nightly; minimal and tabs render correctly via run-headless.sh; a
throwaway example confirmed the standalone-image bind-group path renders;
rigs/gpu-probe, updated to the new empty feature/limit set, confirms
request_device succeeds on the ai-app-2 emulator's software Vulkan
(EMU_GPU=software) -- see TEXTURES.md's "Implemented, 2026-09-04" for the
exact command and output. RUST.md's blocking item is resolved.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-04 22:28:54 -04:00
irisandClaude Fable 5.1 1c937e2f48 LAYOUT.md: single-draw design with an O(1) move chain; IRIS.md for notable API changes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 22:14:51 -04:00
irisandClaude Fable 5.1 d194d73439 LAYOUT.md: Iris's single-draw preference, recorded before design
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:59:38 -04:00
irisandClaude Fable 5.1 4400966928 TEXTURES.md: review -- wgpu-hal gate located, a Patch-cancels-Push bug, and a sort-free shape
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:49:41 -04:00
irisandClaude Sonnet 5 6e49ce8c92 TEXTURES.md: how iris should render an unbounded number of images
Written for review before iris's render core changes. Covers the bindless
binding-array problem, the gpu-probe measurements (emulator and sourced
real-hardware findings), what growth already costs today in the current
code, the egui_wgpu/Vello prior art, and the recommendation with its open
questions -- not yet implemented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:36:56 -04:00
irisandClaude Sonnet 5 79b9cd789a RUST.md: iris's bindless texture array does not survive real Android GPUs
Iris asked whether the 'unknown number of images' approach even works on
mobile. It does not, measured with a new rig (rigs/gpu-probe, no APK
needed) and sourced rather than recalled: the emulator's software Vulkan
refuses iris's descriptor-indexing request outright, and on real hardware
the current Android Vulkan Profile baseline (80.1% of active devices)
does not require VK_EXT_descriptor_indexing either -- Arm's own docs say
only Valhall/5th-Gen Mali (2019+) support it.

iris already solved the identical problem for text in I1 (the glyph
atlas). The recommendation is to generalize it to images rather than
widen the binding array further; not yet implemented, since it changes
iris's render core.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:17:18 -04:00
irisandClaude Opus 5 c70a670356 RUST.md: the Masonry IME gap is a TODO, and the accesskit abort is reproducible
Two things E1 left open, both settled on the emulator.

The missing autocorrect is Masonry's as_input_connection returning None,
not android-view and not EditorInfo: android-view's own demo implements
the trait over a parley editor and Gboard suggests from that buffer.

The abort seen once is a client *detaching*: accesskit_android's adapter
never returns to Inactive, so the first tree change after a ui-trace run
sends an accessibility event with accessibility off, which throws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:44:05 -04:00
irisandClaude Opus 5 43743ba171 RUST.md: bring it up to date, and say it must be kept that way
Adds a "Keep this file current as you work" section at the top saying what
it is for: this file is the handoff, so writing results into it as they
arrive is what lets a session that has filled its context be cleared
instead of carrying the conversation or re-deriving what was measured. It
asks for the dead ends too, since those are what stop the next session
spending an afternoon somewhere already ruled out.

Adds a "Where things stand" block, because the next agent's first question
is which box is next and the answer was previously spread across the list:
E0, E1, I0a, I0b and I1 done, I2 next with E2 able to run in parallel,
client-core not started, and the two emulator-tools changes made outside
this repo.

Corrects what had gone stale: the next-agent steps still said to start at
E0; the iris section still described a fourteen-gate cosmic-text tree and
called the text stack an open question; and the weight section still spoke
of E1 as something that would happen. It now carries the numbers instead --
43s and 2.1 GB against 1m46s and 1.5 GB for iris, and 181 MB debug against
11 MB release for the Masonry demo. Adds the rule about bounding heavy runs
with a kill timer scoped to the pid, which cost a wrong conclusion here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:32:41 -04:00
irisandClaude Opus 5 1a97d0ef5c RUST.md: record I1 -- parley and the atlas, and what is still unmeasured
Iris decided for parley directly rather than through the comparison this
step described, and asked for the glyph atlas with it, so the step is what
was built rather than what was chosen between. Records the view count
dropping from 6 to 1 as the evidence the atlas is doing its job, and says
plainly that the speed claim behind the TODO is still unmeasured in both
directions -- it wants I5's transcript screen to be worth timing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:28:22 -04:00
irisandClaude Opus 5 ff7e9c0435 Test iris's editor, which had no coverage and was just rewritten
iris has no tests at all, and the rule here is not to erect a harness where
there is none -- but the editor is the exception on both counts. It is the
one part of the library that is pure logic over a string and a layout rather
than something needing a GPU and a window, and it was just rewritten
wholesale onto parley's selection model with no way to exercise it: input
cannot be synthesised in the headless compositor the examples run under,
because it has no seat devices.

Fourteen tests over insert, backspace, delete, span clearing, select-all,
motion, single- versus multi-line, and take. Two are there for specific
things the rewrite could plausibly have broken: the IME preedit path, which
resends its whole composition each keystroke so `replace` has to remove
exactly what it added last time, and editing text with multi-byte
characters, since parley addresses by byte offset where the old code
counted (line, index).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:27:57 -04:00
irisandClaude Opus 5 68a7f41ed0 Move iris's text onto parley, with a glyph atlas
Two changes that only make sense together, because the atlas is what the
new layout feeds.

Parley replaces cosmic-text for layout and shaping, and its editing model
replaces the hand-written one. That is the larger win in edit.rs: parley
addresses text by byte offset into one string rather than by (line, index),
so `select_content`, `delete_between`, `insert_inner` and `newline` become
ordinary string operations, and `iter_layout_lines`, `index_x` and
`cursor_pos` -- which walked runs by hand to place the caret and the
selection boxes -- are deleted in favour of `Selection::geometry` and
`Cursor::geometry`. Those are bidi- and wrap-correct, which the hand-written
versions were not. The file loses about 130 lines and gains Home/End.

The atlas is what the TODO's "text resizing (per frame) is really slow" was
about. Every string used to be rasterised into its own RgbaImage and
uploaded as a whole texture whenever anything changed -- so a window resize
re-rasterised and re-uploaded every visible string. Now a glyph is
rasterised once per font, size and subpixel phase and shared by every string
containing it, and a resize re-emits quads without touching the GPU's copy.
The tabs example says so directly: its `views` counter, the number of
texture views bound, goes from 6 to 1.

Supporting pieces: a GLYPH primitive that samples a sub-rectangle and tints
it, since the existing texture primitive samples a whole texture; a Patch
texture update, because re-uploading a 4 MB page per glyph is what an atlas
exists to avoid; and GpuTextures now keeps its Textures, as a view cannot be
written through.

Two bugs found on the way. `primitives!`'s @count rule recursed with commas
while matching space-separated tokens, so it only terminated for exactly two
primitives -- adding a third hit the recursion limit. And Color had no
Default, which parley's Brush requires.

Drops cosmic-text and unicode-segmentation, and with them two nightly
feature gates that nothing uses any more: portable_simd (the old glyph
compositing) and gen_blocks (the deleted line iterator). Eleven gates left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:26:33 -04:00
irisandClaude Opus 5 9b331a5e93 Call pre_present_notify, so a settled frame actually reaches the screen
About one start in five, the window kept its 800x600 startup layout on a
1920x1200 surface for good. It was not the layout: tracing iris's own
decisions into memory -- eprintln in the draw path makes the fault vanish,
which is why it kept getting lost -- gives byte-identical traces for a good
and a bad run. Both do redraw_all at (1920, 1200) and draw into a 1920x1200
texture with suboptimal=false. The right frame was drawn every time and the
compositor kept showing the first one, and forcing a full repaint did not
shift it.

winit's Window::pre_present_notify, called immediately before present, is
what ties the commit to the surface's frame callback on Wayland. Without it
a frame with nothing following it can sit unpresented with nothing left to
flush it -- which is precisely a window that has just settled after its
opening resize.

0 bad in 40 with the fix, against 4 in 20 without. The stronger number is
0 in 20 in the instrumented configuration that had been 15 in 20, since
that is the arrangement the fault liked most. Runtime resizing still
round-trips to a byte-identical layout.

Ruled out and not worth re-trying: the present mode (the fault survived
AutoNoVsync -> AutoVsync at the same rate) and the size cache (redraw_all
clears it). desired_maximum_frame_latency = 1 moved the rate without
fixing it and was reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:10:50 -04:00
irisandClaude Opus 5 3fc224b584 RUST.md: E1 passes, with the keyboard gap it was there to find
The Masonry demo on android-view builds, renders through Vulkan, exposes
its AccessKit tree to ui-trace, and takes real keystrokes from the phone's
own keyboard. What it does not get is autocorrect and suggestions, and the
control is what makes that a finding: the same three key taps in the
Settings search field on the same device produce Gboard's suggestion strip,
and in Masonry's editor they produce nothing. That is the constraint the
framework decision turns on, so it is now the first thing I2 has to answer.

Also closes the Vulkan line this file had flagged as untested. The missing
step was -no-snapshot-load: the guest keeps the old GPU config from its
snapshot and reports zero Vulkan devices however the host is set up. And
records the watchdog trap that produced one wrong conclusion on the way --
a bounded run's kill timer must be scoped to the pid it guards, or it fires
into somebody else's experiment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:51:10 -04:00
irisandClaude Opus 5 10500ae8aa RUST.md: record what E1 showed, and drop an overreaching comment
E1 is part-done: the Masonry demo builds with cargo-ndk and Gradle and
renders, and ui-trace reads its AccessKit tree, so the bench rig's
tap-by-name would work against a Masonry screen. The keyboard half -- the
condition the whole framework decision turns on -- was not reached, so the
box stays open. Also records that two variables changed at once between the
crashing and working runs, so neither can be credited yet.

The vsync comment claimed a redraw burst here lands on the host's desktop.
That was my attribution for a freeze which turned out not to be mine, and
it is machine-specific reasoning that has no business in a library's
source. The battery argument is the whole reason and stands on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:38:35 -04:00
irisandClaude Opus 5 8d441d3d59 Present iris with vsync
AutoNoVsync accepts frames as fast as the GPU will take them, so a redraw
burst costs whatever the hardware can be made to do rather than one frame.
That is the wrong default for a toolkit whose stated goal is to save
battery, and it is worse than wrong on this machine: the GPU here is the
host's real one reached through virtio-gpu, so frames nobody will see are
paid for on somebody's desktop.

AutoVsync picks Fifo, which every backend supports.

Note this is not an idle drain -- iris only draws when needs_redraw says
something changed, and the tabs example guards its stats string -- so this
bounds the cost of a burst rather than stopping a spin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:23:10 -04:00
irisandClaude Opus 5 e0ee7d6e94 RUST.md: record E0 and I0b, with what they measured
E0 is done (NDK r29, cargo-ndk 4.1.2, verified by cross-compiling to both
ABIs) and I0b is done. Corrects this file's guess at why iris would not
build, notes the const-traits family as the gates to re-read whenever the
pin is advanced, and records the cold build weight against the "slow in
debug" worry: 43s and 2.1 GB plain, 1m46s and 1.5 GB with dependencies at
opt-level 2.

Also records an open defect found on the way -- iris sometimes keeps its
pre-configure window size for good -- with what was ruled out, since it
is timing-sensitive enough that any added print hides it, and I2 will
meet it on every rotation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:47:44 -04:00
irisandClaude Opus 5 b6b0928087 Take winit out of iris-core, which makes it build for Android
iris-core wanted exactly one thing from winit: PhysicalSize<u32> in
UiRenderNode::resize's signature, for two numbers it immediately turned
into floats. That pulled a whole windowing backend into the layer below
it. `resize` takes `impl Into<Vec2>` now, matching UiRenderState::resize
beside it.

The consequence is the reason: with winit in the graph, an Android build
of the core failed in android-activity, which needs a backend feature
nothing here selects and which iris should not be going through at all --
the plan is android-view. Without it, `cargo ndk -t arm64-v8a -P 26 build
-p iris-core` produces an rlib in 30s with wgpu's Android backend
included. So the widget, layout and render core already builds for the
phone, and what remains is the surface, the input and the IME.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:47:44 -04:00
irisandClaude Opus 5 12221ea025 Make iris ask for the frame a resize needs
`update` redrew everything when `resized` was set, but `needs_redraw` --
which is what decides whether to request a frame at all -- did not know
about `resized`. A condition in one and not the other is a frame nobody
asks for and a stale window. The two share one `needs_redraw_all` now.

Latent on Wayland, because winit requests a redraw after a resize by
itself; a resize changes neither the root nor any widget, so nothing else
here would have asked. It stops being latent on Android, where the
surface work will not have winit underneath it and every rotation and
keyboard open is a resize.

This is not a fix for the startup defect recorded in RUST.md, where the
window keeps its pre-configure layout: that reproduces with this change
in place, and the frame it needs is requested and drawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:47:44 -04:00
irisandClaude Opus 5 5e23c8b0c0 Add a headless runner for iris examples
This VM has no display but does have a real GPU -- Vulkan 1.4 through
Venus and GL 4.6 through virgl, onto the host's card -- so the only thing
missing for a winit window is a compositor. Same trick `emu` uses for the
Android emulator: a headless sway, with grim for the picture.

It starts its own compositor rather than joining `emu`'s. sway tiles, so
adding a window to the one an emulator sits in resizes that emulator, and
a peer session's `emu up` could join at any moment. Xwayland is off here
because winit speaks Wayland; `emu` forces it on only because the Android
emulator's renderer speaks GLX.

It waits for the window to be mapped rather than sleeping a fixed time:
the first version's fixed sleep captured an all-black screen when sway
had started in the same invocation, which is indistinguishable from an
app that draws nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:47:27 -04:00
irisandClaude Opus 5 caaa733caa Make iris build: pin a dated nightly and migrate const-trait impls
The vendored January tree did not parse at all on a current nightly: 36
errors in iris-core, all from one syntax change. `impl const Trait for T`
is now `const impl Trait for T`, with generics on the `impl`. Bounds are
unaffected, and the traits were already declared `const trait` -- so the
diagnosis recorded in RUST.md was wrong, and pinning back to a January
nightly would only have deferred this. Everything else (the unresolved
UiVec2/Vec2/impl_op imports, a Color<u8> resolving to wgpu_types::Color)
cascaded from the seven files that failed to parse.

The pin is dated rather than `nightly` because that is exactly the
failure: a rolling channel moving under a build Dev Updater runs
unattended. It carries the components and Android targets too, so a
fresh clone provisions itself.

Also drops two `#![feature]` gates the compiler reports as declared and
unused, since the build stays warning-clean, and takes rustfmt's import
order in attr.rs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:47:27 -04:00
irisandClaude Fable 5.1 4ab26f068e Vendor iris, the in-house UI library, at iris/
Iris's decision: it lives in this repository for now, included by path,
with its history left in the iris/iris repository on the gitea remote
(this is its main at 7b54aaf, byte-identical to the public GitHub copy).
It gets its own repository back once it has proved itself here.

RUST.md's I0 records the decision and what the first build said: the
tree does not compile on the current nightly because const_trait_impl
now requires traits to be declared 'const trait', which is the first
item of I0b.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:17:16 -04:00
irisandClaude Fable 5.1 0f8ba49f4a Add RUST.md: the plan for moving the app to Rust
Research and measurements from 2026-09-04: what the Compose app has to
reproduce, why Android text input and rich selectable text decide the
framework, the options considered (Masonry as the yardstick, iris as the
in-house library to build up; Slint, iced, egui, Makepad rejected with
reasons), how thin the Java shell can be, building the APK without
Gradle, and the ordered experiments with pass conditions. Includes the
emulator Vulkan findings: Venus is blocked by this emulator's gfxstream,
SwiftShader over the emulator's own ICD works.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:12:09 -04:00
400 changed files with 76029 additions and 32014 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
../../.claude/skills/ai-app-rigs
+10
View File
@@ -0,0 +1,10 @@
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
# every crate in this repo into one workspace -- they are deliberately
# independent (see scripts/run-tests.sh, which cds into each).
#
# `cargo xtask apk` **from the repo root** runs scripts/xtask/src/main.rs.
# The manifest path is relative to the working directory cargo is run from,
# so the root is where it works; this file is found from any directory
# inside the checkout, but the path inside it is not.
[alias]
xtask = "run --quiet --manifest-path scripts/xtask/Cargo.toml --"
+18 -154
View File
@@ -72,19 +72,13 @@ Each exists because something was invisible without it.
`usage::Fixture`'s, since those are its states. With none set an echo `usage::Fixture`'s, since those are its states. With none set an echo
session meters nothing, which is the ordinary case and draws no bar. session meters nothing, which is the ordinary case and draws no bar.
- **A fake CLI exercises the process lifecycle without a token.** Point a - **A fake CLI exercises the process lifecycle without a token.** Point a
`claude_cli` provider's `command` at a script that ordinarily runs `claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
`cat > /dev/null` and it behaves the way the lifecycle code cares about: `cat > /dev/null` and it behaves the way the lifecycle code cares about:
it holds the fifo open, records a real pid, writes nothing, and dies on a it holds the fifo open, records a real pid, writes nothing, and dies on a
signal. So adopt, stop, restart and start are all drivable without a real signal. So adopt, stop, restart and start are all drivable without a real
`--resume` and without spending a turn on somebody's account. Reach for `--resume` and without spending a turn on somebody's account. Reach for
this when what is under test is *whether a process is running*, and for this when what is under test is *whether a process is running*, and for
`debug-transcript.sh` when it is *what the transcript draws*. The sandbox's `debug-transcript.sh` when it is *what the transcript draws*.
version also handles `auth login`: it prints an inert Anthropic-shaped URL,
rejects any code except `sandbox-code`, and exits successfully for that one.
- **`/think [seconds]` in an echo session puts up a thinking card**, long
enough to watch it spin before it closes with the span it actually took.
The rest of the turn is the ordinary echo reply, so it is also the rig for
a block and a reply meeting.
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens - **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
the first session (or `-k` keeps the current screen), scrolls a fixed the first session (or `-k` keeps the current screen), scrolls a fixed
gesture loop, and prints the app's render report — the same one the in-app gesture loop, and prints the app's render report — the same one the in-app
@@ -147,107 +141,28 @@ moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
### Testing llama.cpp and ssh here ### Testing llama.cpp and ssh here
**Both are set up here** and need nothing typed. The prebuilt llama.cpp lives **Both are set up here as of 2026-09-04** and need nothing typed. The
outside the repo at `~/.local/opt/llama.cpp-vk` — a **Vulkan** build as of prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp`
2026-09-19, replacing the CPU one that was there before — and is symlinked as (the 15 MB `ubuntu-x64` release asset) and is symlinked as
both `~/.local/bin/llama-server` and `/usr/local/bin/llama-server`. The second `/usr/local/bin/llama-server`, which is what makes **discovery find it over
is what makes **discovery find it over ssh**: `~/.local/bin` is not on the ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets.
PATH a non-interactive ssh session gets. It resolves its own libraries through It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is
`$ORIGIN`, so no `LD_LIBRARY_PATH` is needed. needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`,
639 MB under `~/.local/share/ai-app/models` — and answers at usable speed on
Two models are downloaded under `~/.local/share/ai-app/models`: this VM's 8 cores. **Do not test with a 2-bit quant**: the
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
- `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, 639 MB, loads in ~4s. It broken driver — `llama-cli` produces the same from the file directly, which
calls tools correctly and is the right rig for the driver's shape. Do not is how to tell the two apart in a hurry.
judge *answers* by it — asked for the second line of a file it read from
line 2 and then named the third.
- `ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF/Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp.gguf`,
12 GB, ~20s to load, and the only one here with a multi-token-prediction
head. It is the rig for anything about `loading` being a state of its own,
since 20s is long enough to send into.
- `ggml-org/SmolVLM-256M-Instruct-GGUF/SmolVLM-256M-Instruct-Q8_0.gguf`,
175 MB, plus the `mmproj-…` beside it, downloaded 2026-09-20 as the rig for
**vision**: it is the only model here that reads pictures, it loads in
seconds on the CPU, and it described a red circle correctly. The pair is
also what exercises the projector being found beside the weights, and the
projector being kept out of the models a provider offers. Qwen3-0.6B beside
it is the other half of that rig -- the model that answers `refused`.
**A second llama.cpp is installed here, and it is the rig for a custom
build.** `~/.local/share/ai-app/llama/prism/` is Prism ML's fork
(`prism` branch, `~/repos/llama.cpp-prism`, Vulkan, `cmake --install
--prefix`), so discovery finds it as a provider called `llama-cpp-prism`
beside the ordinary `llama-cpp`. It is what exercises that mechanism at all,
and it serves `prism-ml/Ternary-Bonsai-2-27B-gguf` -- ternary packings stock
llama.cpp rejects as unknown types. Rebuild it with
`cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_VULKAN=ON
-DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_INSTALL_RPATH='$ORIGIN/../lib'`, about six
minutes at `-j8`.
**Both of those install flags are load-bearing, and the failure is a session
that never becomes ready.** `llama-server` is a 16 KB launcher against
`libllama-server-impl.so`, so a build whose libraries it cannot find dies at
`exec` with `error while loading shared libraries` -- which reaches the phone
as the model never answering. The runpath has to be set, *and* the libraries
have to be where it points: `GNUInstallDirs` chooses `lib64` on some
distributions (Gentoo's amd64 profiles among them) while the runpath above
says `lib`. `readelf -d bin/llama-server | grep RUNPATH` and
`ldd bin/llama-server | grep 'not found'` are the two-second check after any
install here.
**Which Bonsai packing runs on the GPU is the backend's question, not the
model's.** Measured 2026-09-21 with `llama-bench -p 512 -n 64 -r 2 -fa 1
-ngl 99` on the free card:
| packing | backend | pp512 | tg64 |
| --- | --- | ---: | ---: |
| `PTQ1_0`, 5.53 GiB | Vulkan | 519 t/s | 7.5 t/s |
| `PQ2_0`, 7.21 GiB | **CPU**, 8 cores | unfinished after 9 min | -- |
The fork's Vulkan port covers `PTQ1_0` only -- shaders, a `mul_mat_vec` and
the FWHT included -- so `PQ2_0` has no kernel there and every matmul falls
back to the CPU. That reads exactly like a stuck load: the process sits at
700% CPU for minutes with the card idle. On CUDA and HIP it is the other way
round, since `mmq.cu` guards `PTQ1_0` out of the HIP build (it wants Turing
MMA) and leaves `PQ2_0` in. **ROCm cannot be tested in this VM**: there is no
`/dev/kfd`, because the GPU here is virtio-gpu rather than a passed-through
card.
7.5 tok/s is the honest speed of that Vulkan kernel, against 42 for the
IQ3_S 27B beside it -- smaller weights, slower decode. Nothing is
misconfigured; the fork's fast kernels are CUDA and Metal.
**Do not test with a 2-bit quant**: the IQ2_XXS of the 0.6B produces fluent
nonsense, which reads exactly like a broken driver — `llama-cli` produces the
same from the file directly, which is how to tell the two apart in a hurry.
**The GPU is shared and llama-server dies loudly when it runs out.** A second
server loading a model while the 27B holds VRAM fails with `radv/amdgpu:
Failed to allocate a buffer` / `MESA: error: buffer allocation failed` and
exits mid-request. `-ngl 0` runs it on the 8 cores instead, which is the way
to test the driver while something else holds the card -- through the app, that
is the model's "Layers on the GPU" set to 0 in the machines tab's provider
view, and `--models-max` above 1 is how two models come to be loaded at once
in the first place.
**Testing tools and MCP without the app**: `llama-server --tools all` publishes
its built-in tools at `GET /tools` and runs one at `POST /tools` with
`{"tool": …, "params": …}` and an `x-tool-cwd` header — so a whole agent loop
is drivable with `curl` and no model at all. The Exa MCP server at
`https://mcp.exa.ai/mcp` answers **without an API key** and needs a
`User-Agent` header (Cloudflare answers 403 without one, which reads as a
refusal rather than a missing header).
There is no second machine, so **ssh this VM to itself**. That is set up There is no second machine, so **ssh this VM to itself**. That is set up
too: the key is `~/.config/ai-app/ssh-self` (its public half is in too: the key is `~/.config/ai-app/ssh-self` (its public half is in
`~/.ssh/authorized_keys`, labelled removable), and the real config carries a `~/.ssh/authorized_keys`, labelled removable), and the real config carries a
machine called **"this vm over ssh"** — `bob@127.0.0.1` with that setup called **"this vm over ssh"** — `bob@127.0.0.1` with that
`identityFile` plus `identityFile` plus
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]` `options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the
whole rig for "does a remote llama session work", since the far machine is whole rig for "does a remote llama session work", since the far machine is
this one and the model file is the same file. For a throwaway machine of your this one and the model file is the same file. For a throwaway setup of your
own, point a provider's `command` at something harmless like `/bin/echo` own, point a provider's `command` at something harmless like `/bin/echo`
rather than at `claude`: the transport is what is under test, the process rather than at `claude`: the transport is what is under test, the process
exiting immediately is the signal, and it costs no tokens. The remote login exiting immediately is the signal, and it costs no tokens. The remote login
@@ -285,7 +200,7 @@ never be able to close the app, whatever produced it.
**Deleting a session offers to take the machine's own transcript with it** **Deleting a session offers to take the machine's own transcript with it**
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
confirmation, and only where the driver keeps a record of its own confirmation, and only where the driver keeps a record of its own
(`keepsOwnTranscript`, currently Claude Code or Codex). Off by default, (`keepsOwnTranscript`, which today means Claude Code). Off by default,
because leaving that copy is what makes an ordinary delete recoverable — and because leaving that copy is what makes an ordinary delete recoverable — and
the dialog's paragraph is rewritten when it is on rather than appended to, the dialog's paragraph is rewritten when it is on rather than appended to,
since the sentence promising the conversation "should still be there to since the sentence promising the conversation "should still be there to
@@ -295,57 +210,6 @@ where it was instead of half-deleted.
## Measurements worth not re-taking ## Measurements worth not re-taking
- **`-np 1` is what makes the MTP draft head pay.** Taken 2026-09-19 on the
27B above, decode speed for a 300-token reply, from `llama-server`'s own
timings rather than the clock:
| flags | tok/s |
| --- | --- |
| plain, any `-np` | 41.5 |
| `--spec-type draft-mtp -np 1` | 61.4 |
| `--spec-type draft-mtp -np 2` (n-max 2) | 65.9 |
| `--spec-type draft-mtp`, default `-np` (4 slots) | 28 |
Draft acceptance is 0.530.73 in every case, so the head is working in all
of them: what changes is that speculating against a KV cache split four ways
is slower than not speculating. A model's preset gets `parallel = 1` unless
its settings say otherwise (the machines tab's provider view, since
2026-09-19), so this is recorded for whoever next sees MTP look broken or
next raises the slot count to answer two sessions at once. `--spec-draft-n-max 2` was
worth another 7% in a single sample and is deliberately *not* passed — one
sample on a virtualised GPU is not a number to hardcode.
- **Prompt processing is the expensive part of a llama turn here, and decode
speed falls only slowly with context.** Taken 2026-09-19 on a free GPU, the
27B with `--spec-type draft-mtp -np 1`, generating 160 tokens each time:
| context | decode | prefill of that prompt |
| --- | --- | --- |
| 88 | 43.4 tok/s (cold) | 21s |
| 1,569 | 55.5 tok/s | (model still warming) |
| 6,068 | 53.2 tok/s | 9.5s |
| 14,068 | 50.3 tok/s | 22s |
So a turn on a long conversation spends tens of seconds before the first
token, and that is what `SessionStatus::Reading` exists to say. The same
sweep on the 0.6B **on the CPU** falls much harder -- 30.1 tok/s at 44
tokens of context to 11.5 at 6,024 -- which is the shape somebody means by
"it gets slower as the conversation goes on". The figure the app draws is
`timings.predicted_per_second`, decode only, so prefill is never mixed into
it.
- **A busy GPU is a model that will not load at all**, not a slow one:
`radv/amdgpu: Failed to allocate a buffer` and `failed to load model` while
something else holds VRAM. A 0.6B that had been decoding at 149 tok/s ran at
16.7 in that window before its server died, so a tok/s figure taken while
the card is shared says nothing about the model.
- **Asking for the head when the file has none is fatal**, not ignored:
`context type MTP requested but model doesn't contain MTP layers` and the
server exits. Without the flag the same file logs `unused tensor
blk.N.nextn.* — ignoring` and runs normally, which is the state to look for
when MTP is silently not happening.
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU - **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
emulator against a real imported transcript with the server at emulator against a real imported transcript with the server at
`--delay 120`. Settled and flinging fast, both into fresh history and back `--delay 120`. Settled and flinging fast, both into fresh history and back
+19
View File
@@ -55,4 +55,23 @@ components: [
// the terminal the QR would be printed on. // the terminal the QR would be printed on.
enroll: "server/enroll-link.sh", enroll: "server/enroll-link.sh",
), ),
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
// with the same release key as "app" above so the two can install
// over each other -- a separate component, not a mode of "app" above,
// because it is a different applicationId (com.example.aiapp.shell)
// built by a different tool from different sources. No `cwd`: it
// defaults to this checkout's root, which both the `cargo xtask`
// alias (`.cargo/config.toml`, resolved relative to the working
// directory cargo is run from) and `cargo xtask apk`'s own publishing
// step (`scripts/build/outputs/apk/<mode>/*.apk`, matching
// discover.rs's `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's
// module doc) both need. The publish directory is `scripts/build`
// rather than `scripts/xtask/build` for exactly that reason: the
// pattern is one directory deep, and the tool moved two on 2026-09-09.
Apk(
name: "shell",
modes: ["release", "debug"],
build: "cargo xtask apk",
),
], ],
+16
View File
@@ -1,12 +1,19 @@
.gradle/ .gradle/
build/ build/
app/androidApp/build/ app/androidApp/build/
app/shellApp/build/
local.properties local.properties
.kotlin/ .kotlin/
*.iml *.iml
.idea/ .idea/
.DS_Store .DS_Store
server/target/ server/target/
event-model/target/
app-rust/target/
# E3's native library, built by cargo-ndk straight into the Gradle module
# (RUST.md) -- an artifact, like server/target/ above, not source.
app/shellApp/src/main/jniLibs/
# Server logs from a development run (ai-server.log by convention, # Server logs from a development run (ai-server.log by convention,
# wg-test.log from ./test-wg-tunnel.sh). # wg-test.log from ./test-wg-tunnel.sh).
@@ -21,3 +28,12 @@ certs/
config.ron config.ron
config.json config.json
sessions/ sessions/
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
iris/target/
# The packaging xtask and the GPU rigs, both under scripts/. `build/`
# above already covers scripts/build/outputs/apk, where `cargo xtask apk`
# publishes for Dev Updater to find.
scripts/xtask/target/
scripts/rigs/gpu-probe/target/
+499 -457
View File
File diff suppressed because it is too large. Load diff
-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.
-1941
View File
File diff suppressed because it is too large. Load diff
-289
View File
@@ -1,289 +0,0 @@
# Subagents
A session's subagents -- helpers started by Claude Code's Task tool or Codex's
collaboration tools -- each get a transcript of their own, listed in a panel
over the open session and readable in the same transcript view the session has.
Designed 2026-09-05; extended to Codex's multiplexed app-server threads on
2026-09-13. The decisions Bryan has not yet reviewed are in `DECISIONS.md`.
## What a subagent is here
**A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls.** It is not a session: it cannot be
messaged, stopped or started, and it has no machine, model or usage of its
own. Everything it shares with a session -- the transcript file format, the
paging routes, the SSE stream, the phone's cache and rendering -- is reused
by addressing, not by copying.
Claude reports a subagent's messages on the parent's own stream-json output,
each carrying `parent_tool_use_id` = the id of the Task `tool_use` that started
it. Before this the translator dropped those lines
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
them to that subagent's own translator and transcript. The parent's
transcript still shows only the Task call itself.
Codex app-server multiplexes every thread in the session tree onto the root
process's stdout. Its notifications carry `threadId`; `subAgentActivity`
items name the child thread and its lifecycle, and `collabAgentToolCall`
items carry the spawn prompt. The Codex translator routes a non-root
`threadId` exactly as Claude routes a `parent_tool_use_id`. The child thread
id is the subagent id on disk. An asynchronously delivered `agentMessage` is
a `PeerMessage`, not assistant text from the recipient. Its delta notification
does not repeat the completed item's `delivery` field, so the translator
remembers that field from `item/started` and suppresses those deltas. Letting
one into the recipient's provisional assistant row makes its next completed
message replace the combined row, visibly erasing text that Codex still has.
The parent draws the initial `spawnAgent` as its ordinary `Task` card and
closes it when the matching `subAgentActivity.started` arrives. The remaining
collaboration calls remain visible as coordination -- waiting, messaging,
listing and lifecycle controls -- rather than being mistaken for generic task
output. Null optional fields and a bare `completed` status carry no information
and are omitted; their useful result is the child transcript, status or peer
message beside them.
## Storage
Under the session directory:
```
<session>/subagents/<subagent_id>/meta.json {title, created}
<session>/subagents/<subagent_id>/transcript.jsonl same SeqEvent lines as the session's
```
The id is Claude's Task tool_use id (`toolu_…`) or Codex's child thread id.
Both are unique, stable across a backend restart, and already the key their
parent-side lifecycle uses.
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
id becomes a path.
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
`read_window`, `catch_up` and `read_after` work on it unchanged.
Its path out: deleting the session deletes its directory, subagents included,
and `POST /sessions/{id}/subagents/delete` removes finished ones on their own
-- all or nothing, and refused while any named one is still running, since its
transcript is still being written to and its process is the session's to stop.
## Lifecycle, as events in the subagent's transcript
1. Created when the parent Task/Agent call is seen. A current Claude CLI's
`task_started` with `task_type: local_agent` is a recovery source when an
adopted stream begins after that call. A bare `parent_tool_use_id` is not
enough: other operations can also parent nested lines, and treating one as
proof created false subagents named after their first subcommand.
First lines written:
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
the prompt is known -- it genuinely is the subagent's first user turn.
2. Every child line is translated by that subagent's own `Translator`
(one per subagent: tool ids are unique but streaming deltas are by
content-block index, and parallel subagents interleave).
3. **What ends a subagent is the CLI's own task lifecycle**, on top-level
`system` lines that carry no `parent_tool_use_id`: `task_started`
(`task_id`, `tool_use_id`, `task_type`, `is_backgrounded`, the prompt),
`task_progress` repeatedly, then `task_updated` (`patch.status`, naming the
*task* only) and `task_notification` (`tool_use_id`, `status`, and `summary`
-- the agent's own report). `translate_task` keeps the
`task_id -> tool_use_id` mapping from the first so the update can be
attributed, records the summary as the subagent's closing text, and writes
`Status Exited`. A
`completed` update is deliberately not the end: its notification carries
the summary and would otherwise land after the ending. Any other terminal
status ends it from the update, since the failure to avoid is a subagent
nothing ever finishes.
Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is
the authoritative level beside those edges: its set replaces the previous
set, so a missed terminal edge cannot leave a subagent running forever. Its
ids are deliberately not correlated with the edge stream; what is read off
each entry is its own description and kind, and what is read off the set is
whether it is empty and how large. The session API and stream expose that
size as `backgroundTasks`, which the phone draws beside the status, and
`GET /sessions/{id}/background` serves the entries themselves -- listed in
the session's panel *above* the subagents and never as subagent cards. An
`ambient` entry is excluded from both, on the CLI's own instruction: a
live-update watcher is not activity. A backgrounded subagent is legitimately
in both lists, since it is both running and a transcript. The edges still
carry mapping, outcome and closing summary. On adoption the driver sends a repeated `initialize`,
which makes a current CLI send the full set; an older CLI accepts it and sends no level,
leaving the edge-based path unchanged. A snapshot is reconciled immediately
when the persisted parent status proves it is between turns, and otherwise
at the next `result` boundary -- while a turn is open, a foreground agent is
legitimately absent from the background set. Reconciliation writes
`Status Exited`, which is also what makes a formerly stale row deletable;
a task notification ordered after the level can still add its summary.
The two rules this replaces were both wrong, in opposite directions. The
parent's `tool_result` is not it: a backgrounded Task's arrives at launch
("Async agent launched..."), so ending there truncated a running agent's
transcript at the moment it started. Nor is the subagent's own
`end_turn`: measured against 2.1.237 on 2026-09-06, **a subagent's lines
carry no `stream_event` at all** -- they are whole `user`/`assistant`
lines with a null `stop_reason`, no `result` line is sent for one, and the
sub's final report never appears as a child line -- so that rule could
never fire and every subagent stayed `running` for ever. `ends_a_turn` is
kept as a second detector for a dialect that does say either, and must
never be the only one again.
`Status Exited` either way; the subagent's vocabulary has no `Idle` or
`Waiting`, so the end-of-turn status `dispatch` produces for an ordinary
session is dropped rather than written.
**The ending reaches the parent's transcript as nothing at all**
(2026-09-06). It was tried, and a row per finished subagent is a screenful
of dividers about work the reader was not asking after; the closing report
is *this* transcript's last line and here is where somebody reads it. What
the parent gets a row for is a message a subagent genuinely sends it, which
arrives by the peer path. A backgrounded *command* is the other half of
this and goes the other way: it has no transcript of its own, so its report
updates the tool card that launched it, which was still saying the command
was running. The two lifecycle shapes are still handled once:
whichever gets there first is the one that finds the task still open, and
`finish` below closes it. See PLAN.md's "Two turns must never be drawn as
one".
**While any task is outstanding the session's turn ends in
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
and a session with a backgrounded subagent is not doing that. The edge
fallback has two sources: the translator's `open_tasks`, and
`Subagents::any_open` -- which covers a subagent launched before a backend
restart adopted the session, whose `task_started` is behind the durable
stdout offset. On current Claude versions the replace-semantics level above
reconciles both at a safe turn boundary.
**A limit the account hits inside a subagent is hoisted to the session**
as well as recorded here, because `resume.rs` can only schedule against a
session, and a background subagent outliving its parent's turn is the
ordinary case -- see PLAN.md's "A limit a subagent hits is the session's".
4. **A child line for a subagent that already finished reopens it**
(`Status Running`) rather than being dropped: a background Task can be
sent another message long after its first turn ended, and that is
exactly what a further line for it means. Same transcript, same child
`Translator`, just picking back up.
5. When the parent session's process exits (`Status Exited` on the
session), every subagent still `Running` gets `Status Exited` too: its
process was the parent's. Read from the directory rather than from the
live map, because one left `Running` by a previous run of the server is
precisely the one nothing in this process has touched -- and it would
otherwise read `running` again every time its session was started.
For Codex the same lifecycle is expressed by app-server rather than Claude's
task notices: `subAgentActivity.started` creates the child,
`subAgentActivity.interacted` reopens it, and `completed` or `interrupted`
finishes it. A child's own `turn/completed` is not its end; it remains running
until that activity edge. The root's `turn/completed` reports `waiting` while
the registry contains an open child, and the last activity completion reports
`idle` if the root is between turns. Because the child thread id is also the
on-disk id, an adopted driver can route and finish a child whose spawn record
is already behind the durable stdout offset. The registry's open count is also
Codex's `backgroundTasks` measurement: lifecycle changes send it through the
same event and session-summary fields as Claude's provider snapshot. The other
part of that measurement is app-server's runtime
`thread/backgroundTerminals/list` set. Its process ids are held only in memory
and added to the open-child count; the driver refreshes the set at terminal
boundaries and while it remains nonempty, rather than decrementing for an
unmatched ending edge.
A subagent that was mid-flight when the backend restarted keeps working:
the registry reopens the existing transcript on the next child line, and
the file continues its sequence -- the same reopening #4 describes, whether
what closed it was a restart or its own `end_turn`. If its turn ended while
the backend was down nothing recorded that until the next line arrives, so
its last status stays `Running`, which the list reports as **unknown**
rather than as running (see the wire shape) until then.
Title: for Claude, the Task call's `description` input, then
` (<subagent_type>)` when one is given; falling back to `Task` when the
description is absent. An adopted current CLI can recover the same fields from
its `local_agent` lifecycle record. For Codex, the first lifecycle record uses
the spawned thread's name or the last segment of `agentPath`, with underscores
shown as spaces, then falls back to `subagent`.
## Server layout
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk, and
`delete(ids)` -- its path out. Drivers get an
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
- `session/claude/translate.rs` -- routes child lines by parent id, holds
one child `Translator` per subagent, remembers pending Task calls'
description/prompt/subagent_type.
- `session/codex/translate.rs` -- routes multiplexed app-server notifications
by thread id, remembers collaboration prompts, and translates activity
edges into the same registry lifecycle.
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
subagents at once, each named "helper k". Each writes the prompt as its
user message, streams a few words of text, runs one `Bash` tool call, then
finishes about three seconds after starting, and the parent's Task calls
end when their subagent does. Three seconds so the running state can be
seen on the phone.
- `routes.rs` -- four routes, in the doc table.
## Wire shape
```
GET /sessions/{id} SessionInfo gains `subagents: N` (count, 0 when none)
GET /sessions same field on each row
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
POST /sessions/{id}/subagents/delete {subagents} -> 204; refused whole if one is running
```
The delete is a batch rather than a `DELETE` per id for the reason the import
list's is: the phone deletes what a reader selected, and one request per row
means a batch can half-arrive, leaving the rows that were missed looking
exactly like rows nobody picked. Unlike an import delete it is local file
removal, so it is done by the time the reply is sent and there is no per-row
state to follow afterwards. What decides "running" is
`Subagents::list`'s own rule, shared through `routes::has_a_process` so the
list and the delete cannot disagree about it.
`status` is the transcript's last `Status` event, serialised like a session's
(`running`, `exited`), except that a subagent whose session is not itself
running cannot be running: the list answers `unknown` for that one. A
subagent never reports `waiting`: that is a session's word for having
outstanding work of its own, and a subagent has none. The
phone words these as *running*, *finished* and *unknown* on the subcard.
The count on `SessionInfo` is a directory listing, so the list stays cheap.
The per-subagent status is only read when the list route is asked for.
## Phone
- The subcards are ordered **still running first, then most recently
active** -- a display decision made on the phone (`subagentOrder`), over the
server's stable oldest-first answer. Two keys rather than activity alone
because a subagent that is thinking reports nothing meanwhile and would sink
below one that just finished.
- **Holding a subcard selects it, and several at a time**, exactly as the
import list works, with the selection bar drawn inside the panel rather than
at the bottom of the session: this selection belongs to the subagent list,
and a bar under the composer would read as acting on the conversation.
Delete is
*disabled*, with the reason in words, while anything selected is still
running. Deleting confirms first, dims the rows it is acting on
(`BusyItem`), and on success takes them out of the panel without refetching
anything else. The phone's cached copy of
a deleted subagent's transcript is purged with it.
- The main session list does not expand or count subagents. Swiping left over
an open session pulls an 88%-wide panel in from the right and fetches
`/sessions/{id}/subagents`; it draws one `OutlinedCard` per subagent: title,
then the status word and a relative time. The transcript remains composed
under the panel, so its event stream, draft and scroll position stay live.
Horizontal scrollers inside the transcript win the gesture. Collapsing one,
or starting over any ordinary part of the session, gives the gesture back to
the panel; Android keeps its own edge Back gesture. Swiping right on the
panel, tapping outside it, or Back closes it.
- Tapping a subcard opens a `SessionScreen` layer in **read-only** form: the
same transcript, paging, cache, selection,
images and status row, with the composer, the process button, the model
picker, the files button, the settings cog and the usage bar left out.
The header shows the subagent's title with the session's title beneath it.
It is another layer over the still-composed session and its panel; Back
returns to the panel.
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
cache take a transcript address rather than a session id --
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
subagent's copy under its session's and the same code serves both.
-57
View File
@@ -1,57 +0,0 @@
# TODO
Working list from Iris, 2026-09-03. Remove an entry when it lands; annotate
one in place when it turns out to need a decision.
## App — transcript
- [ ] Decide how running background tasks can be inspected. For now the session
status shows only the provider-reported count; command details stay in
their existing tool cards and must not become subagent cards.
- [ ] Messages received from other agents are inconsistent — sometimes they
appear, sometimes they don't. **Needs a rig.** Read the code rather than
measured: a live Claude session only learns of a peer message from the
`origin` object on a turn's `result`
(`session/claude/translate.rs`), which the CLI attaches to a turn the
message *started*. So a message that arrives mid-turn, or a second one
within one turn, has nowhere to be reported — while an imported session,
which syncs from the CLI's own file, picks up every one of them. That
would show exactly as "sometimes". Confirming it means driving a real
stream-json session and sending it messages in both states.
## Session settings
- [ ] Autocompact belongs in session settings; empty disables it, which is the
default. Iris chose "hand it to the driver" — only where a driver has
auto-compaction of its own. **That option was offered on a false premise
and is not buildable yet.** It named pi's `set_auto_compaction`, but pi
was never built as a driver here: `session/llama.rs` talks to
`llama-server`'s OpenAI-compatible endpoint directly, and its `compact()`
refuses outright. Claude Code's auto-compaction is the CLI's own and
nothing in the stream-json control protocol this app uses configures it.
So the setting would be stored, passed to a driver, refused by every one
of them, and the field would never appear on any session. What is needed
first is either a driver that can take it, or a different rule — the
server watching `contextTokens` and running `/compact` itself is the one
that would work today, for Claude sessions, and it is the option that was
not chosen.
## A session the server could not load
- [ ] **A session whose transcript will not parse is skipped with nothing but a
log line, and from the phone it looks exactly like an idle unresponsive
one.** `SessionManager::new` catches a failing `launch` and logs
"couldn't relaunch session <id>", so the session has no pump and no
driver: no status, no history, nothing sendable. That is what the
`taskNote` incident (fd71d87) looked like from Bryan's phone, and why it
needed a report from him rather than being visible in the app.
`Event::Unreadable` removes the cause that time, but not the class — an
unreadable `process.json`, a provider edited away and an unreachable host
all reach the same place.
This is the "design the unknown state first" rule: a session the server
could not load is not a session with nothing to say, and only the phone
can show the difference. It needs a status the wire can carry for it —
the failure with its reason, reported on the session itself — rather than
the reader having to tell it apart from silence.
+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
+5202
View File
File diff suppressed because it is too large. Load diff
+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"]
+90
View File
@@ -0,0 +1,90 @@
plugins {
id("com.android.application")
}
// The Rust side (this directory's Cargo.toml) is built separately with
// `cargo ndk`, straight into src/main/jniLibs/ -- see the repo-root
// AGENTS.md-style comment at the top of Cargo.toml for why this crate
// stays outside the main Rust workspace, and RUST.md's I2 for the exact
// build command.
android {
namespace = "dev.iris.android.demo"
compileSdk = 37
defaultConfig {
applicationId = "dev.iris.android.demo"
// 29, not 26: `iris::android::view`'s touch handler dates each
// sample with `MotionEvent.getEventTimeNanos` and
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
// method there is a hard crash on the first touch rather than a
// degraded fling. Raised deliberately rather than guarded at
// runtime: nothing this app is built for runs below 29, and an
// untested fallback path is its own defect. `build-apk.sh`'s
// `cargo ndk -P` is kept at the same number.
minSdk = 29
// 37, matching `compileSdk` and the Compose app in `app/` -- which
// is the one part of this that is measured rather than reasoned:
// that app targets 37 and its keyboard does push the transcript up
// on Iris's phone, and this one targeted 34 and does not
// (2026-09-07). The emulator here is API 36 and the push-up works
// there at either target, so the target is the only difference the
// two devices do not share.
//
// The mechanism, stated as the reading it is: below targetSdk 35
// a window keeps the legacy behaviour, where `adjustResize` shrinks
// the window for the IME and `getInsets(ime()).bottom` therefore
// measures the overlap with an already-shrunk window -- zero, with
// nothing left to push up. `MainActivity`'s
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
// 36 it still takes; Android 16 deprecated it and Android 17 is
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
// the app is handed the real overlap without relying on a
// deprecated call. If the phone still reports `ime_bottom=0` with
// a nonzero `dispatches` in the Diagnostics pane, this reading was
// wrong and the `WindowInsetsAnimation.Callback` in
// `MainActivity` is the other half to look at.
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
// A release build must be signed, and the key is per machine rather than per repo -- same
// reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone
// recognises the app by, and a secret never lives in a checkout (the mount is shared with an
// untrusted VM). `build-apk.sh` generates this key once and points at it through the
// environment; without it a release build here is unsigned, which is fine for everything
// except installing.
def keystore = System.getenv("AI_APP_KEYSTORE")
signingConfigs {
if (keystore != null) {
release {
storeFile = file(keystore)
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
keyAlias = "ai-app"
keyPassword = storePassword
}
}
}
buildTypes {
debug {
}
// P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so`
// itself is built separately with `cargo ndk --release --features "transcript-screen
// force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) --
// Gradle here only packages and signs whatever is already there, the same division as the
// debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it
// installable beside a debug build of the tabs demo rather than replacing it.
release {
applicationIdSuffix ".bench"
if (keystore != null) {
signingConfig = signingConfigs.release
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
@@ -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);
}
}
@@ -0,0 +1,153 @@
package org.linebender.android.rustview;
import android.os.Bundle;
import android.os.Handler;
import android.view.KeyEvent;
import android.view.inputmethod.CompletionInfo;
import android.view.inputmethod.CorrectionInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputContentInfo;
class RustInputConnection implements InputConnection {
private final RustView mView;
RustInputConnection(RustView view) {
mView = view;
}
private long getViewPeer() {
return mView.mViewPeer;
}
@Override
public CharSequence getTextBeforeCursor(int n, int flags) {
return mView.getTextBeforeCursorNative(getViewPeer(), n);
}
@Override
public CharSequence getTextAfterCursor(int n, int flags) {
return mView.getTextAfterCursorNative(getViewPeer(), n);
}
@Override
public CharSequence getSelectedText(int flags) {
return mView.getSelectedTextNative(getViewPeer());
}
@Override
public int getCursorCapsMode(int reqModes) {
return mView.getCursorCapsModeNative(getViewPeer(), reqModes);
}
@Override
public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
return null;
}
@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
return mView.deleteSurroundingTextNative(getViewPeer(), beforeLength, afterLength);
}
@Override
public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
return mView.deleteSurroundingTextInCodePointsNative(getViewPeer(), beforeLength, afterLength);
}
@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
return mView.setComposingTextNative(getViewPeer(), text.toString(), newCursorPosition);
}
@Override
public boolean setComposingRegion(int start, int end) {
return mView.setComposingRegionNative(getViewPeer(), start, end);
}
@Override
public boolean finishComposingText() {
return mView.finishComposingTextNative(getViewPeer());
}
@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
return mView.commitTextNative(getViewPeer(), text.toString(), newCursorPosition);
}
@Override
public boolean commitCompletion(CompletionInfo text) {
return false;
}
@Override
public boolean commitCorrection(CorrectionInfo correctionInfo) {
return false;
}
@Override
public boolean setSelection(int start, int end) {
return mView.setSelectionNative(getViewPeer(), start, end);
}
@Override
public boolean performEditorAction(int editorAction) {
return mView.performEditorActionNative(getViewPeer(), editorAction);
}
@Override
public boolean performContextMenuAction(int id) {
return mView.performContextMenuActionNative(getViewPeer(), id);
}
@Override
public boolean beginBatchEdit() {
return mView.beginBatchEditNative(getViewPeer());
}
@Override
public boolean endBatchEdit() {
return mView.endBatchEditNative(getViewPeer());
}
@Override
public boolean sendKeyEvent(KeyEvent event) {
return mView.inputConnectionSendKeyEventNative(getViewPeer(), event);
}
@Override
public boolean clearMetaKeyStates(int states) {
return mView.inputConnectionClearMetaKeyStatesNative(getViewPeer(), states);
}
@Override
public boolean reportFullscreenMode(boolean enabled) {
return mView.inputConnectionReportFullscreenModeNative(getViewPeer(), enabled);
}
@Override
public boolean performPrivateCommand(String action, Bundle data) {
return false;
}
@Override
public boolean requestCursorUpdates(int cursorUpdateMode) {
return mView.requestCursorUpdatesNative(getViewPeer(), cursorUpdateMode);
}
@Override
public Handler getHandler() {
return null;
}
@Override
public void closeConnection() {
mView.closeInputConnectionNative(getViewPeer());
}
@Override
public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) {
return false;
}
}
@@ -0,0 +1,287 @@
package org.linebender.android.rustview;
import android.content.Context;
import android.graphics.Rect;
import android.os.Bundle;
import android.view.Choreographer;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeProvider;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
public abstract class RustView extends SurfaceView
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
// Vendored from android-view bec6c62. The only local change is `protected`,
// allowing IrisView to forward insets through this native peer.
protected final long mViewPeer;
final InputMethodManager mInputMethodManager;
protected abstract long newViewPeer(Context context);
public RustView(Context context) {
super(context);
mViewPeer = newViewPeer(context);
getHolder().addCallback(this);
mInputMethodManager =
(InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
}
private native int[] onMeasureNative(long peer, int widthSpec, int heightSpec);
@Override
protected void onMeasure(int widthSpec, int heightSpec) {
int[] result = onMeasureNative(mViewPeer, widthSpec, heightSpec);
if (result != null) {
setMeasuredDimension(result[0], result[1]);
} else {
super.onMeasure(widthSpec, heightSpec);
}
}
private native void onLayoutNative(
long peer, boolean changed, int left, int top, int right, int bottom);
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
onLayoutNative(mViewPeer, changed, left, top, right, bottom);
super.onLayout(changed, left, top, right, bottom);
}
private native void onSizeChangedNative(long peer, int w, int h, int oldw, int oldh);
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
onSizeChangedNative(mViewPeer, w, h, oldw, oldh);
super.onSizeChanged(w, h, oldw, oldh);
}
private native boolean onKeyDownNative(long peer, int keyCode, KeyEvent event);
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
return onKeyDownNative(mViewPeer, keyCode, event) || super.onKeyDown(keyCode, event);
}
private native boolean onKeyUpNative(long peer, int keyCode, KeyEvent event);
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
return onKeyUpNative(mViewPeer, keyCode, event) || super.onKeyUp(keyCode, event);
}
private native boolean onTrackballEventNative(long peer, MotionEvent event);
@Override
public boolean onTrackballEvent(MotionEvent event) {
return onTrackballEventNative(mViewPeer, event) || super.onTrackballEvent(event);
}
private native boolean onTouchEventNative(long peer, MotionEvent event);
@Override
public boolean onTouchEvent(MotionEvent event) {
return onTouchEventNative(mViewPeer, event) || super.onTouchEvent(event);
}
private native boolean onGenericMotionEventNative(long peer, MotionEvent event);
@Override
public boolean onGenericMotionEvent(MotionEvent event) {
return onGenericMotionEventNative(mViewPeer, event) || super.onGenericMotionEvent(event);
}
private native boolean onHoverEventNative(long peer, MotionEvent event);
@Override
public boolean onHoverEvent(MotionEvent event) {
return onHoverEventNative(mViewPeer, event) || super.onHoverEvent(event);
}
private native void onFocusChangedNative(
long peer, boolean gainFocus, int direction, Rect previouslyFocusedRect);
@Override
protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
onFocusChangedNative(mViewPeer, gainFocus, direction, previouslyFocusedRect);
}
private native void onWindowFocusChangedNative(long peer, boolean hasWindowFocus);
@Override
public void onWindowFocusChanged(boolean hasWindowFocus) {
super.onWindowFocusChanged(hasWindowFocus);
onWindowFocusChangedNative(mViewPeer, hasWindowFocus);
}
private native void onAttachedToWindowNative(long peer);
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
onAttachedToWindowNative(mViewPeer);
}
private native void onDetachedFromWindowNative(long peer);
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
onDetachedFromWindowNative(mViewPeer);
}
private native void onWindowVisibilityChangedNative(long peer, int visibility);
@Override
protected void onWindowVisibilityChanged(int visibility) {
super.onWindowVisibilityChanged(visibility);
onWindowVisibilityChangedNative(mViewPeer, visibility);
}
private native void surfaceCreatedNative(long peer, SurfaceHolder holder);
@Override
public void surfaceCreated(SurfaceHolder holder) {
surfaceCreatedNative(mViewPeer, holder);
}
private native void surfaceChangedNative(
long peer, SurfaceHolder holder, int format, int width, int height);
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
surfaceChangedNative(mViewPeer, holder, format, width, height);
}
private native void surfaceDestroyedNative(long peer, SurfaceHolder holder);
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
surfaceDestroyedNative(mViewPeer, holder);
}
void postFrameCallback() {
Choreographer c = Choreographer.getInstance();
c.removeFrameCallback(this);
c.postFrameCallback(this);
}
void removeFrameCallback() {
Choreographer.getInstance().removeFrameCallback(this);
}
private native void doFrameNative(long peer, long frameTimeNanos);
@Override
public void doFrame(long frameTimeNanos) {
doFrameNative(mViewPeer, frameTimeNanos);
}
private native void delayedCallbackNative(long peer);
private final Runnable mDelayedCallback =
new Runnable() {
@Override
public void run() {
delayedCallbackNative(mViewPeer);
}
};
boolean postDelayed(long delayMillis) {
return postDelayed(mDelayedCallback, delayMillis);
}
boolean removeDelayedCallbacks() {
return removeCallbacks(mDelayedCallback);
}
private native boolean hasAccessibilityNodeProviderNative(long peer);
private native AccessibilityNodeInfo createAccessibilityNodeInfoNative(
long peer, int virtualViewId);
private native AccessibilityNodeInfo accessibilityFindFocusNative(long peer, int virtualViewId);
private native boolean performAccessibilityActionNative(
long peer, int virtualViewId, int action, Bundle arguments);
@Override
public AccessibilityNodeProvider getAccessibilityNodeProvider() {
if (!hasAccessibilityNodeProviderNative(mViewPeer)) {
return super.getAccessibilityNodeProvider();
}
return new AccessibilityNodeProvider() {
@Override
public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
return createAccessibilityNodeInfoNative(mViewPeer, virtualViewId);
}
@Override
public AccessibilityNodeInfo findFocus(int focusType) {
return accessibilityFindFocusNative(mViewPeer, focusType);
}
@Override
public boolean performAction(int virtualViewId, int action, Bundle arguments) {
return performAccessibilityActionNative(
mViewPeer, virtualViewId, action, arguments);
}
};
}
private native boolean onCreateInputConnectionNative(long peer, EditorInfo outAttrs);
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
if (!onCreateInputConnectionNative(mViewPeer, outAttrs)) {
return null;
}
return new RustInputConnection(this);
}
native String getTextBeforeCursorNative(long peer, int n);
native String getTextAfterCursorNative(long peer, int n);
native String getSelectedTextNative(long peer);
native int getCursorCapsModeNative(long peer, int reqModes);
native boolean deleteSurroundingTextNative(long peer, int beforeLength, int afterLength);
native boolean deleteSurroundingTextInCodePointsNative(
long peer, int beforeLength, int afterLength);
native boolean setComposingTextNative(long peer, String text, int newCursorPosition);
native boolean setComposingRegionNative(long peer, int start, int end);
native boolean finishComposingTextNative(long peer);
native boolean commitTextNative(long peer, String text, int newCursorPosition);
native boolean setSelectionNative(long peer, int start, int end);
native boolean performEditorActionNative(long peer, int editorAction);
native boolean performContextMenuActionNative(long peer, int id);
native boolean beginBatchEditNative(long peer);
native boolean endBatchEditNative(long peer);
native boolean inputConnectionSendKeyEventNative(long peer, KeyEvent event);
native boolean inputConnectionClearMetaKeyStatesNative(long peer, int states);
native boolean inputConnectionReportFullscreenModeNative(long peer, boolean enabled);
native boolean requestCursorUpdatesNative(long peer, int cursorUpdateMode);
native void closeInputConnectionNative(long peer);
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.4.0" apply false
}
+15
View File
@@ -0,0 +1,15 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "iris-android-demo"
include(":app")
+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() {
DesktopApp::<Client>::run();
}
#[derive(DesktopUiState)]
pub struct Client {
ui_state: DesktopUiState,
#[allow(dead_code)]
screen: Option<ai_app::ui::TranscriptScreen>,
}
impl DesktopAppState 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: DesktopUiState,
rsc: &mut DesktopRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let screen = match ai_app::ui::fixture::open(rsc, &mut ui_state) {
Ok(opened) => {
if let Some(message) = message_argv() {
opened.screen.composer.field.edit(rsc).set(&message);
}
if let Some(text) = typed_argv() {
let field = opened.screen.composer.field;
let redraw = rsc.tasks.redraw_handle();
rsc.spawn_task(async move |mut ctx| {
for ch in text.chars() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
ctx.update(move |state: &mut Client, rsc| {
state.set_focus(Some(field));
let end = rsc[field].text().len();
let mut edit = field.edit(rsc);
if edit.text.caret().is_none() {
edit.set_cursor_byte(end);
}
edit.insert(&ch.to_string());
});
redraw.request_redraw();
}
});
}
if let Some(inset) = ime_argv() {
opened.screen.composer.set_bottom_inset(rsc, inset);
}
Some(opened.screen)
}
Err(message) => {
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
.color(PaintId::WHITE)
.wrap(true)
.pad(dp(16))
.add_strong(rsc)
.any();
ui_state.set_root(rsc, text);
None
}
};
Self { ui_state, screen }
}
}
+208
View File
@@ -0,0 +1,208 @@
use ai_app::client::QuestionOption;
use ai_app::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use iris::prelude::*;
fn main() {
DesktopApp::<Client>::run();
}
#[derive(DesktopUiState)]
pub struct Client {
ui_state: DesktopUiState,
#[allow(dead_code)]
screen: ai_app::ui::TranscriptScreen,
}
fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
FoldedRow::Single(if from_user {
TranscriptItem::UserMsg {
seq,
text: text.to_string(),
attachments: Vec::new(),
}
} else {
TranscriptItem::AssistantMsg {
seq,
text: text.to_string(),
settled: true,
}
})
}
fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem {
tool_call_in("run1", id, tool, input, result)
}
fn tool_call_in(
run: &str,
id: &str,
tool: &str,
input: &str,
result: Option<(&str, bool)>,
) -> TranscriptItem {
TranscriptItem::ToolRun {
seq: 3,
id: id.into(),
run_id: run.into(),
tool: tool.into(),
input: input.into(),
output: result.map(|(out, _)| out.to_string()).unwrap_or_default(),
done: result.is_some(),
failed: result.is_some_and(|(_, failed)| failed),
asks: Vec::new(),
images: Vec::new(),
}
}
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
let mut call = tool_call_in("run2", id, tool, input, None);
if let TranscriptItem::ToolRun { asks, .. } = &mut call {
asks.push(QuestionCard {
seq: 9,
id: format!("{id}-q"),
prompt: "Allow this command?".into(),
header: None,
options: vec![
QuestionOption {
label: "Allow".into(),
description: None,
preview: None,
},
QuestionOption {
label: "Deny".into(),
description: None,
preview: None,
},
],
multi_select: false,
answers: Vec::new(),
});
}
call
}
fn long_output() -> String {
(0..200)
.map(|i| format!("test ai_app::ui::case_{i} ... ok"))
.collect::<Vec<_>>()
.join("\n")
}
fn synthetic_rows() -> Vec<FoldedRow> {
vec![
msg(
1,
true,
"Can you show me a **bold** word, some *italic* text, and `inline code`?",
),
msg(
2,
false,
"# Sure\n\nHere's a [link to the repo](https://example.com/ai-app-2) and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
),
FoldedRow::Tools(vec![
tool_call(
"t1",
"Read",
r#"{"file_path": "src/main.rs"}"#,
Some(("fn main() {}\n", false)),
),
tool_call(
"t2",
"Bash",
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
Some((
"error: could not compile `iris`\nCaused by: linker not found",
true,
)),
),
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
]),
FoldedRow::Single(tool_call(
"t5",
"Bash",
r#"{"command": "cargo test -p transcript-ui -- --nocapture"}"#,
Some((&long_output(), false)),
)),
msg(6, true, "Looks good, thanks!"),
msg(7, false, BLOCK_SAMPLER),
]
}
const BLOCK_SAMPLER: &str = "\
## What changed
Iris **fold** render measure session window anchor context transcript \
iris measure iris scroll call transcript layout *cursor* context, and a \
[bench](https://example.com/bench) link.
```rust
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
let mut out = items;
out.push(Item::new(seq));
out
}
```
| column | value |
|---|---|
| a | measure place draw tool call token context window anchor |
- one bullet
- another, with `inline code`
- nested one level
1. first numbered
2. second numbered
> A quoted line, to show the bar and the indent.
";
impl DesktopAppState for Client {
fn new(
mut ui_state: DesktopUiState,
rsc: &mut DesktopRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let mut screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows());
screen.push_row(
rsc,
&FoldedRow::Single(TranscriptItem::CommandRow {
seq: 8,
text: "clear".into(),
}),
);
screen.push_row(
rsc,
&FoldedRow::Tools(vec![
tool_call_in(
"run2",
"t6",
"Read",
r#"{"file_path": "docs/RUST.md"}"#,
Some(("# Moving the app to Rust\n", false)),
),
tool_call_in(
"run2",
"t7",
"Bash",
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
Some(("error: unused variable `x`", true)),
),
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
asking(
"t9",
"Bash",
r#"{"command": "rm -rf target", "timeout": 120000, "description": "Clear the build"}"#,
),
]),
);
screen.set_session_working(rsc, true);
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
assert!(
screen.expand_tail_tools(rsc, true),
"the newest row must be the tool run this flag is about"
);
}
Self { ui_state, screen }
}
}
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
# Installs and runs the iris `bench` build on this checkout's own emulator
# (per this-machine-android's per-checkout-AVD rule; `emu serial` picks it)
# and prints the report -- the iris half of `app/transcript-bench.sh`'s
# job. No coordinates: the button is found by its accessibility label
# through `ui-trace`, per AGENTS.md's "Driving the UI".
#
# Usage: ./run-bench.sh [--apk PATH]
# Defaults to this checkout's own release APK
# (android-project/app/build/outputs/apk/release/app-release.apk) if it
# exists, else the
# debug one -- build one first with ./build-apk.sh.
set -eu
cd "$(dirname "$0")"
APK=""
while [ $# -gt 0 ]; do
case "$1" in
--apk) APK="$2"; shift 2 ;;
*) echo "run-bench.sh: unknown argument: $1" >&2; exit 1 ;;
esac
done
if [ -z "$APK" ]; then
if [ -f android-project/app/build/outputs/apk/release/app-release.apk ]; then
APK=android-project/app/build/outputs/apk/release/app-release.apk
else
APK=android-project/app/build/outputs/apk/debug/app-debug.apk
fi
fi
if [ ! -f "$APK" ]; then
echo "run-bench.sh: no APK at $APK -- run ./build-apk.sh first" >&2
exit 1
fi
SERIAL=$(emu serial)
PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
if [ -z "$PKG" ]; then
BUILD_TOOLS=$(ls -d "$HOME"/Android/Sdk/build-tools/*/ | sort -V | tail -1)
PKG=$("${BUILD_TOOLS}aapt2" dump badging "$APK" | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
fi
echo "run-bench.sh: installing $APK ($PKG) on $SERIAL"
adb -s "$SERIAL" install -r "$APK" >/dev/null
adb -s "$SERIAL" shell am force-stop "$PKG"
adb -s "$SERIAL" logcat -c
adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/dev/null
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
# Which adapter drew, before any number is printed. The emulator is a GLES
# machine -- its guest has no hardware Vulkan (docs/RUST.md, "What the
# emulator gives a GPU app") -- so iris's runtime fallback lands on `Gl`,
# and `Gl (... virgl ...)` is the host's real GPU while `Gl (...
# SwiftShader ...)` is the CPU. Those two produce frame times an order of
# magnitude apart and are otherwise indistinguishable in this report, so
# the line is printed rather than left in logcat for somebody to think of.
ADAPTER=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null \
| sed -n 's/.*\(iris renderer: .*\)/\1/p' | tail -1)
if [ -n "$ADAPTER" ]; then
echo "run-bench.sh: $ADAPTER"
else
echo "run-bench.sh: no 'iris renderer:' line in logcat -- cannot say what drew this run" >&2
fi
# Poll for the report line rather than a fixed sleep -- the run itself is
# a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
# a longer script than v1's swipe-loop-only run.
# The report's own first line, not the bare "iris bench report:" prefix:
# `copy_report` logs that prefix too ("nothing to copy -- run the benchmark
# first", which the app emits at startup), so polling for the prefix
# returned instantly and the script printed a report that was never run.
REPORT_LINE="iris bench report: iris bench report"
i=0
while [ "$i" -lt 260 ]; do
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
if [ -n "$LINE" ]; then
break
fi
i=$((i + 1))
sleep 1
done
if [ -z "$LINE" ]; then
echo "run-bench.sh: no report after 260s -- check logcat by hand" >&2
exit 1
fi
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
# phases, four lines each) on top of the frames/bench sections v1 had.
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "$REPORT_LINE"
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "nightly"
components = ["clippy", "rustfmt"]
targets = ["aarch64-linux-android", "x86_64-linux-android"]
+101
View File
@@ -0,0 +1,101 @@
//! 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};
use std::{
fs, panic,
path::{Path, PathBuf},
sync::OnceLock,
};
/// Installs the in-process ring in front of Android's logger.
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()
{
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}"),
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";
/// Enough preceding log lines to explain a crash without evicting the next run.
const CRASH_CONTEXT_LINES: usize = 80;
const PREVIOUS_RUN_TARGET: &str = "previous_run";
static CRASH_PATH: OnceLock<PathBuf> = OnceLock::new();
/// Copies aborting panics into the device-readable log ring.
fn install_panic_hook() {
let previous = panic::take_hook();
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() {
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()
});
let _ = fs::write(path, format!("{line}\n{context}"));
}
previous(info);
}));
}
/// Configures crash persistence and replays a report left by the previous run.
pub fn set_crash_dir(dir: &Path) {
let path = dir.join(CRASH_FILE);
if let Ok(previous) = fs::read_to_string(&path) {
// Delete first so a panic during replay cannot create a replay loop.
let _ = 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()
);
}
+888
View File
@@ -0,0 +1,888 @@
use crate::android::bench_jni::PlatformHandle;
use crate::client::transcript_fold::{TranscriptItem, fold_event};
use crate::ui::{self, TranscriptScreen};
use android_view::jni::{JavaVM, objects::GlobalRef};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::{
fs, mem,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
time::{Duration, Instant},
};
const STREAM_EVENTS_PER_SEC: u64 = 20;
const STREAM_SECONDS: u64 = 20;
const LEGACY_CYCLES: usize = 6;
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
const FLING_COUNT: usize = 8;
const FLING_SETTLE_CAP_MS: u64 = 3_000;
const FLING_PAUSE_MS: u64 = 300;
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, \
telecommunications, and interdisciplinary collaboration all push a narrow composer field to \
wrap across several lines while the transcript above is pushed upward by the growing \
keyboard-adjacent box, which is exactly what a real reader typing a long message sees \
happening now!!!";
const TYPE_CHAR_MS: u64 = 50;
const KEYBOARD_CYCLES: usize = 5;
const KEYBOARD_WAIT_MS: u64 = 1_000;
const POLL_MS: u64 = 16;
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
pub struct BenchClient {
ui_state: AndroidUiState,
content: WeakWidget<WidgetPtr>,
report_display: WeakWidget<TextEdit>,
top_bar: WeakWidget<WidgetPtr>,
screen: Option<TranscriptScreen>,
items: Vec<TranscriptItem>,
stream_tail: Vec<SeqEvent>,
platform: Option<Arc<PlatformHandle>>,
last_report: Option<String>,
running: bool,
ime_state: Arc<Mutex<ImeState>>,
keyboard_was_visible: bool,
last_top_pad: f32,
}
#[derive(Default)]
struct ImeState {
visible: bool,
shown_events: u32,
hidden_events: u32,
}
impl HasAndroidUiState for BenchClient {
fn android_state(&self) -> &AndroidUiState {
&self.ui_state
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
&mut self.ui_state
}
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
.any()
}
fn process_cpu_ms() -> Option<u64> {
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
// initialises on success; on failure it is never read.
unsafe {
let mut usage: libc::rusage = mem::zeroed();
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
return None;
}
let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000;
let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000;
Some(user_ms + sys_ms)
}
}
fn peak_rss_kb() -> Option<u64> {
fs::read_to_string("/proc/self/status")
.ok()?
.lines()
.find_map(|line| line.strip_prefix("VmHWM:"))
.and_then(|rest| rest.trim().strip_suffix("kB"))
.and_then(|n| n.trim().parse().ok())
}
fn battery_line(samples: &[i32]) -> String {
if samples.is_empty() {
return " battery current: unavailable on this device".to_string();
}
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
unreachable!("samples is non-empty, checked above");
};
format!(
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
samples.len()
)
}
impl AndroidAppState for BenchClient {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
let content = WidgetPtr::new().add(rsc);
let loading = placeholder(rsc, "Loading fixture...");
content(rsc).set(loading);
let report_display = wtext("")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(true)
.size(14)
.color(PaintId::WHITE)
.attr::<Selectable>(())
.label("Benchmark report")
.add(rsc);
let top_bar = WidgetPtr::new().add(rsc);
let controls = bench_controls(rsc, 0.0);
top_bar(rsc).set(controls);
let tree = (
top_bar,
report_display
.pad(dp(8))
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
content.height(rest(1)),
)
.span(Dir::DOWN)
.add_strong(rsc)
.any();
ui_state.set_root(rsc, tree);
let font = rsc.ui.text.font_diagnostics();
log::info!(
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
bold={:?} italic={:?} mono={:?}, icons={:?}",
font.families_found,
font.default_family,
font.default_mono_family,
font.regular_resolved,
font.bold_resolved,
font.italic_resolved,
font.mono_resolved,
font.icon_family,
);
let mut client = Self {
ui_state,
content,
report_display,
top_bar,
screen: None,
items: Vec::new(),
stream_tail: Vec::new(),
platform: None,
last_report: None,
running: false,
ime_state: Arc::new(Mutex::new(ImeState::default())),
keyboard_was_visible: false,
last_top_pad: 0.0,
};
match ui::fixture::build_screen(rsc) {
Ok((opened, tree)) => {
client.items = opened.items;
client.stream_tail = opened.stream_tail;
(client.content)(rsc).set(tree);
client.screen = Some(opened.screen);
}
Err(message) => {
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
}
}
client
}
fn platform_ready(&mut self, _rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
}
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>) -> bool {
false
}
fn on_insets_changed(
&mut self,
rsc: &mut AndroidRsc<Self>,
insets: iris::android::WindowInsets,
) {
if insets.top != self.last_top_pad {
self.last_top_pad = insets.top;
let controls = bench_controls(rsc, insets.top);
(self.top_bar)(rsc).set(controls);
}
if let Some(screen) = &self.screen {
screen
.composer
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
}
// The platform's own answer, not `ime_bottom > 0.0` -- see
// `iris::android::WindowInsets::ime_bottom`. The height is still
// climbing while the keyboard slides in, so a frame or two of a
// real opening reads as "closed" when the boolean is inferred from
// it, and `shown_events`/`hidden_events` below count transitions.
let ime_visible = insets.ime_visible;
let mut ime = self.ime_state.lock().unwrap();
if ime_visible && !ime.visible {
ime.shown_events += 1;
}
if !ime_visible && ime.visible {
ime.hidden_events += 1;
}
ime.visible = ime_visible;
drop(ime);
if ime_visible && !self.keyboard_was_visible {
self.keyboard_was_visible = true;
let redraw = rsc.tasks.redraw_handle();
rsc.spawn_task(async move |mut ctx| {
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
ctx.update(|state: &mut BenchClient, rsc| {
state.capture_keyboard_diagnostics(rsc);
});
redraw.request_redraw();
});
} else if !ime_visible {
self.keyboard_was_visible = false;
}
}
}
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
type Rsc = AndroidRsc<BenchClient>;
/// What a report says about the `iris::input`/`iris::frame` trace, from
/// the flag read at the start of what is being reported and again at the
/// end.
///
/// Three answers rather than two. Those lines are default-off and the
/// switch that turns them on is on screen while a benchmark runs, so
/// "somebody moved it half way through" is a state that actually happens
/// -- and reported as either "on" or "off" it is a confident sentence
/// about a log that only covers part of the run. The "on" wording also
/// says what it costs, because a traced run fills the ring in seconds and
/// a reader looking at a log with nothing else in it should know why.
fn trace_line(at_start: bool, at_end: bool) -> String {
match (at_start, at_end) {
(true, true) => "input/frame trace: on (iris::input and iris::frame lines are in \
the app log, and a traced run fills the ring in seconds)"
.to_string(),
(false, false) => "input/frame trace: off".to_string(),
_ => "input/frame trace: switched during this run, so those lines cover only part \
of it"
.to_string(),
}
}
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
/// why it needs one at all. A dark neutral rather than pure black
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
/// instead of a hole in the background the buttons happen to float in.
const HEADER_SURFACE: Srgba8 = Srgba8::new(28, 28, 34, 255);
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
/// `on_insets_changed` has run once) -- folded in here, rather than
/// exposing the unadded builder for a caller to `.pad()` itself, because
/// naming that builder's type at each call site is more machinery than a
/// top-of-screen padding number is worth.
const HEADER_TEXT: f32 = 18.0;
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let run_rect = rect(Srgba8::rgb(40, 70, 40))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.start_benchmark(rsc);
},
)
.label("Run benchmark");
let run = (
run_rect,
wtext("Run benchmark")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let copy_rect = rect(Srgba8::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.copy_report(rsc);
},
)
.label("Copy report");
let copy = (
copy_rect,
wtext("Copy report")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let diag_rect = rect(Srgba8::rgb(60, 45, 70))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.show_diagnostics(rsc);
},
)
.label("Diagnostics");
let diagnostics = (
diag_rect,
wtext("Diagnostics")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
// A switch rather than a button, so its own appearance says which
// state it is in: the two `iris::input`/`iris::frame` targets are
// default-off (`iris::diagnostics`'s module doc) because a 120Hz
// session fills the 2000-line ring in seconds, so "is it on right
// now" is the question somebody has while looking at a log that is
// either full of trace or has none.
let tracing = iris::diagnostics::trace_enabled();
let trace_rect = rect(if tracing {
Srgba8::rgb(90, 70, 30)
} else {
Srgba8::rgb(50, 50, 60)
})
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.toggle_trace(rsc);
},
)
.label("Trace input and frames");
let trace = (
trace_rect,
wtext(if tracing { "Trace on" } else { "Trace off" })
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
(rect(HEADER_SURFACE), buttons)
.stack()
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
.pad(Padding::top(top_pad))
.add_strong(rsc)
.any()
}
impl BenchClient {
fn show_message(&mut self, rsc: &mut Rsc, message: &str) {
let widget = placeholder(rsc, message);
(self.content)(rsc).set(widget);
self.screen = None;
}
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
let (screen, tree) = ui::build_tree(rsc, ui::fixture::rows(&self.items));
(self.content)(rsc).set(tree);
self.screen = Some(screen);
}
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
let report = self.diagnostics_text(rsc);
self.report_display.edit(rsc).set(&report);
self.last_report = Some(report);
}
/// Turns the `iris::input`/`iris::frame` trace on or off, redraws the
/// switch that says so, and shows the pane that now reports it.
fn toggle_trace(&mut self, rsc: &mut Rsc) {
let on = !iris::diagnostics::trace_enabled();
iris::diagnostics::set_trace(on);
log::info!(
"iris diagnostics: input/frame trace {}",
if on { "on" } else { "off" }
);
let controls = bench_controls(rsc, self.last_top_pad);
(self.top_bar)(rsc).set(controls);
self.show_diagnostics(rsc);
}
/// The diagnostics report as text, with no side effect on what is on
/// screen -- shared by the `Diagnostics` button (which shows it) and
/// the keyboard-open capture (which only logs it), so the two can
/// never drift into reporting different things.
fn diagnostics_text(&self, rsc: &mut Rsc) -> String {
let font = rsc.ui.text.font_diagnostics();
let frame_report = match self.android_state().frame_report.report() {
Some(stats) => format!("{stats}"),
None => "no frames recorded yet".to_string(),
};
let renderer = match &self.android_state().renderer {
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
};
// Insets must be visible without adb so a missing callback can be
// distinguished from a callback reporting zero IME height.
format!(
"{renderer}\n{}\n{}\n{}\n{}",
trace_line(
iris::diagnostics::trace_enabled(),
iris::diagnostics::trace_enabled()
),
self.android_state().insets_report(),
// Which server this build talks to, and what to do when the
// answer is "none" -- the bench itself opens a checked-in
// fixture and needs no server, so this pane is the only place
// an enrolment can be seen to have taken.
crate::android::enrollment::status_line(),
crate::android::app_log::diagnostics_line()
)
}
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
let report = self.diagnostics_text(rsc);
log::info!("iris keyboard diagnostics:\n{report}");
}
fn copy_report(&mut self, rsc: &mut Rsc) {
let Some(platform) = &self.platform else {
log::info!("iris bench report: no platform handle, can't reach the clipboard");
return;
};
let report = match self.last_report.clone() {
Some(report) => report,
None => format!(
"no benchmark has run yet -- these are the diagnostics instead:\n\n{}",
self.diagnostics_text(rsc)
),
};
if platform.copy_to_clipboard("iris bench report", &report) {
log::info!("iris bench report: copied to clipboard");
} else {
log::info!("iris bench report: clipboard copy failed");
}
}
fn start_benchmark(&mut self, rsc: &mut Rsc) {
if self.running {
log::info!("iris bench report: already running");
return;
}
self.running = true;
self.android_state_mut().frame_report.reset();
self.report_display.edit(rsc).set("Running benchmark...");
let redraw = rsc.tasks.redraw_handle();
let platform = self.platform.clone();
let stream_tail = self.stream_tail.clone();
let ime_state = self.ime_state.clone();
let platform_hz = platform.as_ref().and_then(|p| p.refresh_rate_hz());
let cpu_start = process_cpu_ms();
// Read at the start as well as the end, because the switch is on
// screen while a run is going: a report that only asked afterwards
// would say "on" about a run whose first half has no trace in it
// -- the inferred answer presented as the measured one.
let trace_at_start = iris::diagnostics::trace_enabled();
let run_started_at = Instant::now();
rsc.spawn_task(async move |mut ctx| {
// The battery sampler runs for the whole run, once a second,
// the same cadence `BatterySampler` uses on the Compose side
// -- via its own JNI-attached thread, not `ctx.update`, since
// a sample needs no widget-tree access.
let sampler_done = Arc::new(AtomicBool::new(false));
let samples = Arc::new(Mutex::new(Vec::<i32>::new()));
let sampler = platform.clone().map(|platform| {
let done = sampler_done.clone();
let samples = samples.clone();
tokio::spawn(async move {
while !done.load(Ordering::Relaxed) {
if let Some(value) = platform.battery_current_ua() {
samples.lock().unwrap().push(value);
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
})
});
let travel = run_fling_phase(&mut ctx, &redraw).await;
let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
run_type_phase(&mut ctx, &redraw, &platform).await;
let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
sampler_done.store(true, Ordering::Relaxed);
if let Some(sampler) = sampler {
let _ = sampler.await;
}
let battery = battery_line(&samples.lock().unwrap());
let cpu_line = match (cpu_start, process_cpu_ms()) {
(Some(start), Some(end)) => {
format!(
" process CPU time over this run: {}ms",
end.saturating_sub(start)
)
}
_ => " process CPU time over this run: unavailable".to_string(),
};
let rss_line = match peak_rss_kb() {
Some(kb) => format!(" peak RSS: {kb}kB"),
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
};
let total_seconds = run_started_at.elapsed().as_secs_f64();
ctx.update(move |state: &mut BenchClient, rsc| {
state.running = false;
let now = Instant::now();
let drawn_hz = state.android_state().frame_report.sustained_frame_hz();
let refresh_hz = match (drawn_hz, platform_hz) {
(Some(d), Some(p)) => d.max(p),
(Some(d), None) => d,
(None, Some(p)) => p,
(None, None) => 60.0,
};
let hz_line = match (drawn_hz, platform_hz) {
(Some(d), Some(p)) if d > p + 5.0 => format!(
" (sustained {d:.0}fps, so at least that; the display reported {p:.0}Hz)"
),
(Some(d), Some(_)) => format!(" (as the display reports it; drew {d:.0}fps)"),
(Some(d), None) => {
format!(" (sustained {d:.0}fps; the display would not say)")
}
(None, Some(_)) => {
" (as the display reports it; too few frames to measure)".to_string()
}
(None, None) => " (assumed -- neither measured nor reported)".to_string(),
};
let phase_lines: String = state
.android_state()
.frame_report
.phase_stats(now, refresh_hz)
.iter()
.map(|p| format!("{p}\n"))
.collect();
let per_phase = if phase_lines.is_empty() {
String::new()
} else {
format!("per phase:\n{phase_lines}\n")
};
let frames_block = match state.android_state().frame_report.report() {
Some(stats) => {
let (late, late_pct) =
state.android_state().frame_report.late_at_hz(refresh_hz);
format!(
"frames:\n {} frames over {:.1}s at {:.0}Hz{hz_line} ({:.1}ms \
budget)\n \
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
p99 {:.1}ms\n worst {:.1}ms\n build_p50 {:.1}ms acquire_p50 \
{:.1}ms submit_p50 {:.1}ms",
stats.total_frames,
total_seconds,
refresh_hz,
1000.0 / refresh_hz as f64,
stats.p50.as_secs_f64() * 1000.0,
stats.p90.as_secs_f64() * 1000.0,
stats.p99.as_secs_f64() * 1000.0,
stats.worst.as_secs_f64() * 1000.0,
stats.cpu_p50.as_secs_f64() * 1000.0,
stats.acquire_p50.as_secs_f64() * 1000.0,
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
)
}
None => "frames:\n no frames recorded".to_string(),
};
let scroll_line = format!(
" scroll: {LEGACY_CYCLES} cycles ({} swipes, legacy tween), streamed \
{sent}/{total} fixture events",
LEGACY_CYCLES * 4
);
let fling_line = format!(
" fling: {FLING_COUNT} flings out + {FLING_COUNT} back at \
{FLING_VELOCITY_PX_S}px/s, travel {travel}"
);
let type_line = format!(
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
TYPE_TEXT.chars().count()
);
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
let report = format!(
"iris bench report\n{traced}\n{per_phase}{frames_block}\n\nbench:\n\
{fling_line}\n{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n\
{rss_line}\n{battery}"
);
log::info!("iris bench report: {report}");
state.report_display.edit(rsc).set(&report);
state.last_report = Some(report);
});
redraw.request_redraw();
});
}
}
/// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
/// same `ctx.update` every other mutation here goes through) and returns
/// its result to the caller's async task -- `ctx.update` alone has no way
/// to hand a value back, since the closure only actually runs once the
/// next frame callback drains `IrisViewPeer`'s task channel
/// (`drain_tasks`). **Must call `redraw.request_redraw()` itself, right
/// after enqueueing** -- `ctx.update` only ever pushes onto a channel;
/// nothing drains it until something schedules the frame callback that
/// calls `drain_tasks`, and a caller relying on some *earlier*,
/// already-in-flight `request_redraw()` to cover a *later* `ctx.update`
/// deadlocks the moment that earlier callback has already fired and
/// drained everything queued before this call existed. Cost a real hang
/// in this file's first version of the fling phase: every loop iteration
/// after the first sat forever with nothing scheduled to drain it.
/// Polls rather than assuming one `POLL_MS` sleep is enough, since a
/// slow device's frame callback can lag further than that.
async fn read_from_state<T, F>(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
f: F,
) -> T
where
T: Send + 'static,
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
{
let (tx, rx) = mpsc::channel();
ctx.update(move |state: &mut BenchClient, rsc| {
let _ = tx.send(f(state, rsc));
});
redraw.request_redraw();
loop {
if let Ok(value) = rx.try_recv() {
return value;
}
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
}
}
async fn run_fling_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) -> String {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("fling");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
// `anchor = None` into a real slot before `start` is read.
tokio::time::sleep(Duration::from_millis(POLL_MS * 2)).await;
let start = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
animate_scroll(screen.list, rsc);
}
});
redraw.request_redraw();
wait_for_fling_settle(ctx, redraw).await;
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
}
let outward = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
animate_scroll(screen.list, rsc);
}
});
redraw.request_redraw();
wait_for_fling_settle(ctx, redraw).await;
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
}
let end = read_anchor_position(ctx, redraw).await;
format!("start={start} outward={outward} end={end} ticked=frame-loop")
}
async fn read_anchor_position(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) -> String {
read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).anchor_position_display(),
None => "idx=none".to_string(),
})
.await
}
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
let id = scroll.id();
rsc.ui_mut().animate(id);
}
async fn wait_for_fling_settle(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) {
let cap = Duration::from_millis(FLING_SETTLE_CAP_MS);
let started = Instant::now();
while started.elapsed() < cap {
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).is_scrolling(),
None => false,
})
.await;
if !still_scrolling {
return;
}
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
}
}
async fn run_stream_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
stream_tail: Vec<SeqEvent>,
) -> (usize, usize) {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("stream");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
let mut sent = 0usize;
for event in stream_tail.into_iter().take(total) {
ctx.update(move |state: &mut BenchClient, rsc| {
let old_items = state.items.clone();
state.items = fold_event(&state.items, &event);
match state.screen.as_mut() {
Some(screen) => screen.apply(rsc, &old_items, &state.items),
None => state.rebuild_transcript(rsc),
}
});
redraw.request_redraw();
sent += 1;
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
}
tokio::time::sleep(Duration::from_millis(300)).await;
(sent, total)
}
async fn run_type_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
platform: &Option<Arc<PlatformHandle>>,
) {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("type");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
state.set_focus(Some(screen.composer.field));
}
});
redraw.request_redraw();
if let Some(p) = platform {
p.show_ime();
}
tokio::time::sleep(Duration::from_millis(300)).await;
let mut typed = String::new();
for ch in TYPE_TEXT.chars() {
typed.push(ch);
let text = typed.clone();
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
screen.composer.field.edit(rsc).set(&text);
}
});
redraw.request_redraw();
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
while !typed.is_empty() {
typed.pop();
let text = typed.clone();
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
screen.composer.field.edit(rsc).set(&text);
}
});
redraw.request_redraw();
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
}
}
async fn run_keyboard_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
platform: &Option<Arc<PlatformHandle>>,
ime_state: &Arc<Mutex<ImeState>>,
) -> String {
ctx.update(|state: &mut BenchClient, _rsc| {
state
.android_state_mut()
.frame_report
.mark_phase("keyboard");
});
let mut shown = 0;
let mut hidden = 0;
for _ in 0..KEYBOARD_CYCLES {
let before_shown = ime_state.lock().unwrap().shown_events;
if let Some(p) = platform {
p.show_ime();
}
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
if ime_state.lock().unwrap().shown_events > before_shown {
shown += 1;
}
let before_hidden = ime_state.lock().unwrap().hidden_events;
if let Some(p) = platform {
p.hide_ime();
}
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
if ime_state.lock().unwrap().hidden_events > before_hidden {
hidden += 1;
}
}
if shown == 0 {
format!(" keyboard: could not be shown ({KEYBOARD_CYCLES} attempts, 0 confirmed visible)")
} else {
format!(
" keyboard: shown {shown}/{KEYBOARD_CYCLES}, hidden {hidden}/{KEYBOARD_CYCLES} \
(confirmed via on_insets_changed)"
)
}
}
#[cfg(test)]
mod tests {
use super::TYPE_TEXT;
#[test]
fn type_text_is_exactly_600_characters() {
assert_eq!(TYPE_TEXT.chars().count(), 600);
}
}
+179
View File
@@ -0,0 +1,179 @@
use android_view::jni::{
JNIEnv, JavaVM,
objects::{GlobalRef, JObject, JValue},
};
/// `android.os.BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` -- not exposed
/// as a constant anywhere reachable without the Android SDK jar, so named
/// here with its source rather than left as a bare `2`.
const BATTERY_PROPERTY_CURRENT_NOW: i32 = 2;
pub struct PlatformHandle {
vm: JavaVM,
view: GlobalRef,
}
impl PlatformHandle {
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
Self { vm, view }
}
fn context<'e>(&self, env: &mut JNIEnv<'e>) -> Option<JObject<'e>> {
env.call_method(
self.view.as_obj(),
"getContext",
"()Landroid/content/Context;",
&[],
)
.ok()?
.l()
.ok()
}
fn system_service<'e>(
&self,
env: &mut JNIEnv<'e>,
context: &JObject<'e>,
name: &str,
) -> Option<JObject<'e>> {
let jname = env.new_string(name).ok()?;
env.call_method(
context,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[JValue::Object(jname.as_ref())],
)
.ok()?
.l()
.ok()
}
pub fn battery_current_ua(&self) -> Option<i32> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let battery_manager = self.system_service(env, &context, "batterymanager")?;
let value = env
.call_method(
&battery_manager,
"getIntProperty",
"(I)I",
&[JValue::Int(BATTERY_PROPERTY_CURRENT_NOW)],
)
.ok()?
.i()
.ok()?;
if value == 0 || value == i32::MIN {
None
} else {
Some(value)
}
}
/// Puts `text` on the system clipboard through `ClipboardManager` --
/// `true` only if the whole JNI chain (service lookup, `ClipData`,
/// `setPrimaryClip`) succeeded.
pub fn copy_to_clipboard(&self, label: &str, text: &str) -> bool {
self.try_copy_to_clipboard(label, text).is_some()
}
fn try_copy_to_clipboard(&self, label: &str, text: &str) -> Option<()> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let clipboard = self.system_service(env, &context, "clipboard")?;
let jlabel = env.new_string(label).ok()?;
let jtext = env.new_string(text).ok()?;
let clip = env
.call_static_method(
"android/content/ClipData",
"newPlainText",
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
&[
JValue::Object(jlabel.as_ref()),
JValue::Object(jtext.as_ref()),
],
)
.ok()?
.l()
.ok()?;
env.call_method(
&clipboard,
"setPrimaryClip",
"(Landroid/content/ClipData;)V",
&[JValue::Object(&clip)],
)
.ok()?;
Some(())
}
pub fn refresh_rate_hz(&self) -> Option<f32> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let display = env
.call_method(
self.view.as_obj(),
"getDisplay",
"()Landroid/view/Display;",
&[],
)
.ok()?
.l()
.ok()?;
if display.is_null() {
return None;
}
let rate = env
.call_method(&display, "getRefreshRate", "()F", &[])
.ok()?
.f()
.ok()?;
if rate > 0.0 { Some(rate) } else { None }
}
pub fn show_ime(&self) -> bool {
self.try_toggle_ime(true).unwrap_or(false)
}
pub fn hide_ime(&self) -> bool {
self.try_toggle_ime(false).unwrap_or(false)
}
fn try_toggle_ime(&self, show: bool) -> Option<bool> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let imm = self.system_service(env, &context, "input_method")?;
if show {
env.call_method(
&imm,
"showSoftInput",
"(Landroid/view/View;I)Z",
&[JValue::Object(self.view.as_obj()), JValue::Int(0)],
)
.ok()?
.z()
.ok()
} else {
let token = env
.call_method(
self.view.as_obj(),
"getWindowToken",
"()Landroid/os/IBinder;",
&[],
)
.ok()?
.l()
.ok()?;
env.call_method(
&imm,
"hideSoftInputFromWindow",
"(Landroid/os/IBinder;I)Z",
&[JValue::Object(&token), JValue::Int(0)],
)
.ok()?
.z()
.ok()
}
}
}
+160
View File
@@ -0,0 +1,160 @@
//! The JNI half of `DevLogProvider`: reading this process's own log ring
//! for a `ContentProvider` that Dev Updater queries.
use android_view::jni::JNIEnv;
use android_view::jni::objects::{JClass, JObject, JString};
use android_view::jni::sys::{jlong, jobjectArray};
use std::{path::Path, ptr, 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(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 = 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)
}
+169
View File
@@ -0,0 +1,169 @@
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};
use log::LevelFilter;
use std::{
ffi::c_void,
path::{Path, PathBuf},
};
/// 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 `DesktopAppState::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>) -> 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(Path::new(&dir));
enrollment::set_files_dir(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
}
}
}
+299
View File
@@ -0,0 +1,299 @@
use crate::client::api::{ApiClient, UreqTransport};
use crate::client::event_stream::{StreamItem, follow_session_events};
use crate::client::transcript_fold::{
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
};
use crate::ui::{self, TranscriptScreen};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::{
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
thread,
};
pub struct TranscriptClient {
ui_state: AndroidUiState,
/// The screen's own content -- everything under the fixed
/// [`frame_report_controls`] bar, which is built once (`new`, below)
/// and never touched by `show_message`/`rebuild_transcript`'s own
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
/// by rebuilding the session list beside it.
content: WeakWidget<WidgetPtr>,
screen: Option<TranscriptScreen>,
items: Vec<TranscriptItem>,
session_id: Option<String>,
generation: Arc<AtomicU64>,
}
impl HasAndroidUiState for TranscriptClient {
fn android_state(&self) -> &AndroidUiState {
&self.ui_state
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
&mut self.ui_state
}
}
fn build_transport() -> Result<UreqTransport, String> {
crate::android::enrollment::transport()
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
.any()
}
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
type Rsc = AndroidRsc<TranscriptClient>;
let report_rect = rect(Srgba8::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
.state
.android_state()
.frame_report
.report()
{
Some(stats) => log::info!("iris frame report: {stats}"),
None => log::info!(
"iris frame report: no frames recorded -- scroll first, then press this"
),
},
)
.label("Frame report");
let report = (
report_rect,
wtext("Frame report").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.add(rsc);
let reset_rect = rect(Srgba8::rgb(70, 40, 40))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
ctx.state.android_state_mut().frame_report.reset();
log::info!("iris frame report: reset");
},
)
.label("Reset frame report");
let reset = (
reset_rect,
wtext("Reset").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.add(rsc);
(report, reset).span(Dir::RIGHT).height(56).add(rsc)
}
impl AndroidAppState for TranscriptClient {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
let content = WidgetPtr::new().add(rsc);
let loading = placeholder(rsc, "Loading sessions...");
content(rsc).set(loading);
let tree = (frame_report_controls(rsc), content.height(rest(1)))
.span(Dir::DOWN)
.add_strong(rsc)
.any();
ui_state.set_root(rsc, tree);
let mut client = Self {
ui_state,
content,
screen: None,
items: Vec::new(),
session_id: None,
generation: Arc::new(AtomicU64::new(0)),
};
client.spawn_fetch_sessions(rsc);
client
}
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>) -> bool {
// No screen stack of its own -- same "let the activity finish"
// answer `iris-android-app`'s tabs `Client` already gives.
false
}
}
impl TranscriptClient {
fn show_message(&mut self, rsc: &mut AndroidRsc<Self>, message: &str) {
let widget = placeholder(rsc, message);
(self.content)(rsc).set(widget);
self.screen = None;
}
fn spawn_fetch_sessions(&mut self, rsc: &mut AndroidRsc<Self>) {
let redraw = rsc.tasks.redraw_handle();
let my_generation = self.generation.load(Ordering::SeqCst);
let generation = self.generation.clone();
rsc.spawn_task(async move |mut ctx| {
let outcome = match build_transport() {
Ok(transport) => ApiClient::new(transport)
.fetch_sessions()
.map_err(|e| e.to_string()),
Err(e) => Err(format!("couldn't set up TLS: {e}")),
};
ctx.update(move |state: &mut TranscriptClient, rsc| {
if generation.load(Ordering::SeqCst) != my_generation {
return;
}
match outcome {
Ok(sessions) => match sessions.into_iter().next() {
Some(session) => state.select_session(rsc, session.id),
None => state.show_message(rsc, "No sessions on the sandbox server."),
},
Err(message) => {
state.show_message(rsc, &format!("Couldn't list sessions: {message}"))
}
}
});
redraw.request_redraw();
});
}
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
self.items.clear();
self.session_id = Some(session_id.clone());
self.show_message(rsc, "Loading transcript...");
let redraw = rsc.tasks.redraw_handle();
let live_generation = self.generation.clone();
rsc.spawn_task(async move |mut ctx| {
let transports =
build_transport().and_then(|rest| build_transport().map(|stream| (rest, stream)));
let (rest, stream_transport) = match transports {
Ok(pair) => pair,
Err(e) => {
let message = format!("couldn't set up TLS: {e}");
ctx.update(move |state: &mut TranscriptClient, rsc| {
if live_generation.load(Ordering::SeqCst) == my_generation {
state.show_message(rsc, &message);
}
});
redraw.request_redraw();
return;
}
};
let api = ApiClient::new(rest);
let page: Result<Vec<serde_json::Value>, String> = api
.fetch_transcript_page(&session_id, None, 200, true)
.map_err(|e| e.to_string());
let after = page
.as_ref()
.ok()
.and_then(|values| values.last())
.and_then(raw_seq)
.unwrap_or(0);
let result = page.and_then(|values| fold_page(&values));
{
let live_generation = live_generation.clone();
ctx.update(move |state: &mut TranscriptClient, rsc| {
if live_generation.load(Ordering::SeqCst) != my_generation {
return;
}
match result {
Ok(items) => {
state.items = items;
state.rebuild_transcript(rsc);
}
Err(message) => {
state.show_message(rsc, &format!("Couldn't load transcript: {message}"))
}
}
});
}
redraw.request_redraw();
if live_generation.load(Ordering::SeqCst) != my_generation {
return;
}
let _ =
follow_session_events(
&stream_transport,
&session_id,
after,
move |item| match item {
StreamItem::Open | StreamItem::Reset => {
live_generation.load(Ordering::SeqCst) == my_generation
}
StreamItem::Event { event, .. } => {
if live_generation.load(Ordering::SeqCst) != my_generation {
return false;
}
let live_generation = live_generation.clone();
ctx.update(move |state: &mut TranscriptClient, rsc| {
if live_generation.load(Ordering::SeqCst) != my_generation {
return;
}
state.apply_event(rsc, &event);
});
redraw.request_redraw();
true
}
},
);
});
}
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
let in_progress = self
.screen
.as_ref()
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
.filter(|t| !t.is_empty());
let rows = group_tool_runs(&self.items);
let (screen, tree) = ui::build_tree(rsc, rows);
if let Some(text) = in_progress {
screen.composer.field.edit(rsc).set(&text);
}
if let Some(session_id) = self.session_id.clone() {
let field = screen.composer.field;
rsc.register_event(field, Submit, move |ctx, rsc| {
let text = field.edit(rsc).take();
let text = text.trim().to_string();
if !text.is_empty() {
ctx.state.send_message(session_id.clone(), text);
}
});
}
(self.content)(rsc).set(tree);
self.screen = Some(screen);
}
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
let old_items = self.items.clone();
self.items = fold_event(&self.items, event);
match self.screen.as_mut() {
Some(screen) => screen.apply(rsc, &old_items, &self.items),
None => self.rebuild_transcript(rsc),
}
}
fn send_message(&mut self, session_id: String, text: String) {
thread::spawn(move || {
if let Ok(transport) = build_transport() {
let api = ApiClient::new(transport);
let _ = api.send_message(&session_id, &text, &[]);
}
});
}
}
+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();
}
+456
View File
@@ -0,0 +1,456 @@
use std::ops::Range;
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
/// because the one place that needs partial transparency (dimming) says so
/// with a separate flag rather than baking it into the colour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
}
#[derive(Debug, Clone)]
pub struct AnsiPalette {
pub colours: [Rgb; 16],
pub foreground: Rgb,
pub background: Rgb,
}
/// One span's worth of styling. `None` fields mean "unspecified", the same
/// meaning `Color.Unspecified` and a null `FontWeight` carried in the Kotlin.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Style {
pub color: Option<Rgb>,
/// How much of `color`'s alpha survives, 0.0-1.0; `None` is opaque.
pub alpha: Option<f32>,
pub background: Option<Rgb>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct StyledText {
pub text: String,
pub spans: Vec<(Range<usize>, Style)>,
}
impl StyledText {
fn plain(text: String) -> Self {
Self {
text,
spans: Vec::new(),
}
}
}
const ESC: char = '\u{1B}';
const BELL: char = '\u{7}';
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
if !text.contains(ESC) && !text.contains('\r') {
return StyledText::plain(text.to_string());
}
let chars: Vec<char> = text.chars().collect();
let mut runs: Vec<(String, Option<Style>)> = Vec::new();
let mut sgr = Sgr::PLAIN;
let mut at = 0usize;
let mut plain = String::new();
let flush = |plain: &mut String, sgr: Sgr, runs: &mut Vec<(String, Option<Style>)>| {
if !plain.is_empty() {
runs.push((std::mem::take(plain), sgr.span(palette)));
}
};
while at < chars.len() {
let c = chars[at];
if c == ESC {
flush(&mut plain, sgr, &mut runs);
at = skip_escape(&chars, at, |params, final_byte| {
if final_byte == 'm' {
sgr = sgr.apply(params, palette);
}
});
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
flush(&mut plain, sgr, &mut runs);
drop_line(&mut runs);
at += 1;
} else if c == '\r' {
at += 1;
} else if c >= ' ' || c == '\n' || c == '\t' {
plain.push(c);
at += 1;
} else {
at += 1;
}
}
flush(&mut plain, sgr, &mut runs);
let mut out = String::new();
let mut spans = Vec::new();
for (run_text, style) in runs {
let start = out.len();
out.push_str(&run_text);
if let Some(style) = style {
spans.push((start..out.len(), style));
}
}
StyledText { text: out, spans }
}
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
while let Some((text, style)) = runs.pop() {
if let Some(break_at) = text.rfind('\n') {
runs.push((text[..=break_at].to_string(), style));
return;
}
}
}
fn is_csi_final(c: char) -> bool {
('@'..='~').contains(&c)
}
/// Steps over the escape sequence starting at `at`, reporting a CSI's
/// parameters and final byte. One reader for every kind, because the point
/// is to *leave* them all behind: a sequence this did not recognise would
/// otherwise have its body printed as ordinary text. Three shapes -- the CSI
/// (`ESC [ ... letter`), the string escapes which run to a terminator, and
/// the two-character ones.
fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) -> usize {
let Some(&next) = chars.get(at + 1) else {
return at + 1;
};
match next {
'[' => {
let mut end = at + 2;
while end < chars.len() && !is_csi_final(chars[end]) {
end += 1;
}
if end >= chars.len() {
chars.len()
} else {
let params: String = chars[at + 2..end].iter().collect();
on_csi(&params, chars[end]);
end + 1
}
}
']' | 'P' | 'X' | '^' | '_' => {
let mut end = at + 2;
while end < chars.len() {
if chars[end] == BELL {
return end + 1;
}
if chars[end] == ESC && chars.get(end + 1) == Some(&'\\') {
return end + 2;
}
end += 1;
}
chars.len()
}
_ => at + 2,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct Sgr {
fg: Option<Rgb>,
bg: Option<Rgb>,
bold: bool,
dim: bool,
italic: bool,
underline: bool,
strike: bool,
reverse: bool,
}
const DIM_ALPHA: f32 = 0.65;
impl Sgr {
const PLAIN: Sgr = Sgr {
fg: None,
bg: None,
bold: false,
dim: false,
italic: false,
underline: false,
strike: false,
reverse: false,
};
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
if *self == Sgr::PLAIN {
return None;
}
let front = if self.reverse {
Some(self.bg.unwrap_or(palette.background))
} else {
self.fg
};
let back = if self.reverse {
Some(self.fg.unwrap_or(palette.foreground))
} else {
self.bg
};
// Dim has to have a colour to dim, so where none was named it dims
// the ordinary one.
let stated = front.or(if self.dim {
Some(palette.foreground)
} else {
None
});
Some(Style {
color: stated,
alpha: if self.dim { Some(DIM_ALPHA) } else { None },
background: back,
bold: self.bold,
italic: self.italic,
underline: self.underline,
strikethrough: self.strike,
})
}
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
let codes: Vec<i64> = params
.split(';')
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
.collect();
let mut state = *self;
let mut at = 0usize;
while at < codes.len() {
let code = codes[at];
state = match code {
0 => Sgr::PLAIN,
1 => Sgr {
bold: true,
..state
},
2 => Sgr { dim: true, ..state },
3 => Sgr {
italic: true,
..state
},
4 => Sgr {
underline: true,
..state
},
7 => Sgr {
reverse: true,
..state
},
9 => Sgr {
strike: true,
..state
},
21 | 22 => Sgr {
bold: false,
dim: false,
..state
},
23 => Sgr {
italic: false,
..state
},
24 => Sgr {
underline: false,
..state
},
27 => Sgr {
reverse: false,
..state
},
29 => Sgr {
strike: false,
..state
},
30..=37 => Sgr {
fg: Some(palette.colours[(code - 30) as usize]),
..state
},
90..=97 => Sgr {
fg: Some(palette.colours[(code - 90 + 8) as usize]),
..state
},
40..=47 => Sgr {
bg: Some(palette.colours[(code - 40) as usize]),
..state
},
100..=107 => Sgr {
bg: Some(palette.colours[(code - 100 + 8) as usize]),
..state
},
39 => Sgr { fg: None, ..state },
49 => Sgr { bg: None, ..state },
38 | 48 => {
let (colour, last) = extended_colour(&codes, at, palette);
at = last;
if code == 38 {
Sgr {
fg: colour,
..state
}
} else {
Sgr {
bg: colour,
..state
}
}
}
_ => state,
};
at += 1;
}
state
}
}
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
match codes.get(at + 1) {
Some(&5) => match codes.get(at + 2) {
None => (None, at + 1),
Some(&n) => (Some(indexed_colour(n, palette)), at + 2),
},
Some(&2) => {
let r = codes.get(at + 2);
let g = codes.get(at + 3);
let b = codes.get(at + 4);
match (r, g, b) {
(Some(&r), Some(&g), Some(&b)) => (
Some(Rgb::new(
r.clamp(0, 255) as u8,
g.clamp(0, 255) as u8,
b.clamp(0, 255) as u8,
)),
at + 4,
),
_ => (None, at + 1),
}
}
_ => (None, at + 1),
}
}
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
if n < 0 {
palette.foreground
} else if n < 16 {
palette.colours[n as usize]
} else if n < 232 {
let i = (n - 16) as usize;
Rgb::new(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
} else if n < 256 {
let grey = (8 + (n - 232) * 10) as u8;
Rgb::new(grey, grey, grey)
} else {
palette.foreground
}
}
#[cfg(test)]
mod tests {
use super::*;
fn palette() -> AnsiPalette {
let mut colours = [Rgb::new(0, 0, 0); 16];
for (i, c) in colours.iter_mut().enumerate() {
*c = Rgb::new(i as u8, 0, 0);
}
AnsiPalette {
colours,
foreground: Rgb::new(255, 255, 255),
background: Rgb::new(0, 0, 0),
}
}
fn styled(text: &str) -> StyledText {
ansi_styled(text, &palette())
}
fn style_over(text: &str, word: &str) -> Option<Style> {
let out = styled(text);
let at = out
.text
.find(word)
.unwrap_or_else(|| panic!("no {word:?} in {}", out.text));
out.spans
.iter()
.find(|(range, _)| range.contains(&at))
.map(|(_, style)| *style)
}
#[test]
fn a_colour_becomes_a_span_and_the_sequence_itself_disappears() {
let text = format!("plain {ESC}[31mred{ESC}[0m plain");
assert_eq!(styled(&text).text, "plain red plain");
assert_eq!(
style_over(&text, "red").unwrap().color,
Some(Rgb::new(1, 0, 0))
);
assert!(style_over(&text, "plain").is_none());
}
#[test]
fn bright_background_and_256_colour_forms_all_reach_the_same_table() {
assert_eq!(
style_over(&format!("{ESC}[91mx"), "x").unwrap().color,
Some(Rgb::new(9, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[44mx"), "x").unwrap().background,
Some(Rgb::new(4, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[38;5;1mx"), "x").unwrap().color,
Some(Rgb::new(1, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[38;5;16mx"), "x").unwrap().color,
Some(Rgb::new(0, 0, 0))
);
assert_eq!(
style_over(&format!("{ESC}[38;5;231mx"), "x").unwrap().color,
Some(Rgb::new(255, 255, 255))
);
assert_eq!(
style_over(&format!("{ESC}[38;2;10;20;30mx"), "x")
.unwrap()
.color,
Some(Rgb::new(10, 20, 30))
);
}
#[test]
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
assert_eq!(styled(&text).text, "abcde");
}
#[test]
fn a_carriage_return_rewrites_its_line_as_it_does_on_a_terminal() {
assert_eq!(styled("10%\r50%\rdone\n").text, "done\n");
assert_eq!(styled("kept\r\nfirst\rlast").text, "kept\nlast");
}
#[test]
fn a_sequence_cut_off_mid_stream_takes_no_text_with_it() {
assert_eq!(styled(&format!("text {ESC}[3")).text, "text ");
}
#[test]
fn unstyled_text_costs_no_spans_at_all() {
assert_eq!(styled("nothing to do here").spans.len(), 0);
assert_eq!(styled(&format!("a{ESC}[2Jb")).spans.len(), 0);
}
}
+546
View File
@@ -0,0 +1,546 @@
use std::io::Read;
use event_model::SeqEvent;
use serde::Deserialize;
use serde_json::Value;
/// `status` is the HTTP status where there was a response at all, and
/// `None` where the server was never reached -- mirroring `ApiException` in
/// `Api.kt`.
#[derive(Debug, Clone)]
pub struct ApiError {
pub message: String,
pub status: Option<u16>,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ApiError {}
pub enum Body {
Json(Value),
Bytes {
content_type: String,
bytes: Vec<u8>,
},
}
/// What a transport hands back for a REST call: the status and the body
/// read whole. A streamed body ([`Transport::stream`]) is a different
/// method because its whole point is not reading it whole.
pub struct RawResponse {
pub status: u16,
pub body: Vec<u8>,
}
pub trait Transport: Send + Sync {
fn request(
&self,
method: &str,
path: &str,
body: Option<Body>,
) -> Result<RawResponse, ApiError>;
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
}
/// One session as `GET /sessions` and `GET /sessions/{id}` report it.
/// Mirrors `Api.kt`'s `SessionSummary`; see that type's doc for what each
/// field means and why `setup` is never shown.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSummary {
pub id: String,
pub setup: String,
#[serde(default)]
pub keeps_own_transcript: bool,
pub setup_name: String,
pub provider: String,
pub title: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub permission_mode: Option<String>,
#[serde(default)]
pub imported: bool,
#[serde(default = "default_true")]
pub notify: bool,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub context_tokens: Option<u64>,
#[serde(default)]
pub max_image_edge: Option<u32>,
pub status: String,
pub last_activity: f64,
}
fn default_true() -> bool {
true
}
pub struct ApiClient<T: Transport> {
transport: T,
}
impl<T: Transport> ApiClient<T> {
pub fn new(transport: T) -> Self {
Self { transport }
}
/// The transport underneath, for a caller that needs the raw SSE
/// stream (`event_stream::follow_session_events`) rather than one of
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
/// is the one that does.
pub fn transport(&self) -> &T {
&self.transport
}
fn json_request<R: for<'de> Deserialize<'de>>(
&self,
method: &str,
path: &str,
body: Option<Value>,
) -> Result<R, ApiError> {
let raw = self.transport.request(method, path, body.map(Body::Json))?;
serde_json::from_slice(&raw.body).map_err(|e| ApiError {
message: format!("Reached the server but couldn't read its response ({e})"),
status: Some(raw.status),
})
}
fn empty_request(&self, method: &str, path: &str, body: Option<Value>) -> Result<(), ApiError> {
self.transport.request(method, path, body.map(Body::Json))?;
Ok(())
}
pub fn fetch_sessions(&self) -> Result<Vec<SessionSummary>, ApiError> {
self.json_request("GET", "/sessions", None)
}
pub fn fetch_session(&self, session_id: &str) -> Result<SessionSummary, ApiError> {
self.json_request("GET", &format!("/sessions/{session_id}"), None)
}
pub fn send_message(
&self,
session_id: &str,
text: &str,
attachment_ids: &[String],
) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/message"),
Some(serde_json::json!({ "text": text, "attachmentIds": attachment_ids })),
)
}
pub fn unqueue_message(&self, session_id: &str, message_id: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/unqueue"),
Some(serde_json::json!({ "messageId": message_id })),
)
}
pub fn answer_question(
&self,
session_id: &str,
question_id: &str,
answers: &[String],
) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/answer"),
Some(serde_json::json!({ "questionId": question_id, "answers": answers })),
)
}
pub fn interrupt_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/interrupt"), None)
}
pub fn stop_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/stop"), None)
}
pub fn start_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/start"), None)
}
pub fn rename_session(&self, session_id: &str, title: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/title"),
Some(serde_json::json!({ "title": title })),
)
}
pub fn set_session_cwd(&self, session_id: &str, cwd: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/cwd"),
Some(serde_json::json!({ "cwd": cwd })),
)
}
pub fn set_session_model(&self, session_id: &str, model: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/model"),
Some(serde_json::json!({ "model": model })),
)
}
pub fn set_session_permission_mode(
&self,
session_id: &str,
mode: &str,
) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/permission-mode"),
Some(serde_json::json!({ "permissionMode": mode })),
)
}
pub fn set_session_notify(&self, session_id: &str, notify: bool) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/notify"),
Some(serde_json::json!({ "notify": notify })),
)
}
pub fn run_command(&self, session_id: &str, text: &str) -> Result<(), ApiError> {
self.empty_request(
"POST",
&format!("/sessions/{session_id}/command"),
Some(serde_json::json!({ "text": text })),
)
}
pub fn compact_session(&self, session_id: &str) -> Result<(), ApiError> {
self.empty_request("POST", &format!("/sessions/{session_id}/compact"), None)
}
pub fn delete_session(&self, session_id: &str, delete_foreign: bool) -> Result<(), ApiError> {
let path = if delete_foreign {
format!("/sessions/{session_id}?deleteForeign=true")
} else {
format!("/sessions/{session_id}")
};
self.empty_request("DELETE", &path, None)
}
/// A page of transcript history. `before` is the newest-first cursor
/// (server default is "the newest page" when absent, which a caller
/// gets by passing `None`); the events themselves are handed back as
/// [`event_model::SeqEvent`] via `crate::client::event_stream`'s parsing, kept
/// out of this method's signature so a caller that only wants the raw
/// lines (for the transcript cache) is not forced to parse them.
pub fn fetch_transcript_page(
&self,
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
) -> Result<Vec<Value>, ApiError> {
self.json_request(
"GET",
&transcript_path(session_id, before, limit, coalesce, None),
None,
)
}
pub fn fetch_transcript_lines(
&self,
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
after: Option<u64>,
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
let path = transcript_path(session_id, before, limit, coalesce, after);
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
raw.into_iter()
.map(|value| {
let line = value.get().to_string();
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
message: format!(
"the server sent a transcript line this build couldn't parse: {e}"
),
status: None,
})?;
Ok((line, event))
})
.collect()
}
}
fn transcript_path(
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
after: Option<u64>,
) -> String {
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
if let Some(before) = before {
path.push_str(&format!("&before={before}"));
}
if coalesce {
path.push_str("&coalesce=true");
}
if let Some(after) = after {
path.push_str(&format!("&after={after}"));
}
path
}
pub struct UreqTransport {
agent: ureq::Agent,
base_url: String,
token: String,
}
impl UreqTransport {
pub fn new(
base_url: impl Into<String>,
token: impl Into<String>,
ca_pem: &[u8],
) -> Result<Self, ApiError> {
let cert = ureq::tls::Certificate::from_pem(ca_pem).map_err(|e| ApiError {
message: format!("The pinned CA certificate could not be read: {e}"),
status: None,
})?;
let tls_config = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::new_with_certs(&[cert]))
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls_config)
.http_status_as_error(false)
.timeout_connect(Some(std::time::Duration::from_secs(5)))
.build()
.into();
Ok(Self {
agent,
base_url: base_url.into(),
token: token.into(),
})
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
}
impl Transport for UreqTransport {
fn request(
&self,
method: &str,
path: &str,
body: Option<Body>,
) -> Result<RawResponse, ApiError> {
let url = self.url(path);
let auth = format!("Bearer {}", self.token);
let mut builder = ureq::http::Request::builder()
.method(method)
.uri(&url)
.header("Authorization", &auth);
let response = match body {
None => builder
.body(())
.map_err(ureq::Error::from)
.and_then(|req| self.agent.run(req)),
Some(Body::Json(value)) => {
builder = builder.header("Content-Type", "application/json");
builder
.body(serde_json::to_vec(&value).unwrap_or_default())
.map_err(ureq::Error::from)
.and_then(|req| self.agent.run(req))
}
Some(Body::Bytes {
content_type,
bytes,
}) => {
builder = builder.header("Content-Type", content_type);
builder
.body(bytes)
.map_err(ureq::Error::from)
.and_then(|req| self.agent.run(req))
}
};
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
let status = response.status().as_u16();
let mut body = Vec::new();
response
.body_mut()
.as_reader()
.read_to_end(&mut body)
.map_err(|e| ApiError {
message: format!("Reached {url} but couldn't read its response ({e})"),
status: Some(status),
})?;
if !(200..300).contains(&status) {
return Err(response_error(status, &body, path));
}
Ok(RawResponse { status, body })
}
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
let url = self.url(path);
let auth = format!("Bearer {}", self.token);
let response = self
.agent
.get(&url)
.header("Authorization", &auth)
.header("Accept", "text/event-stream")
.config()
.timeout_recv_response(None)
.build()
.call();
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
let status = response.status().as_u16();
if status != 200 {
let mut body = Vec::new();
let _ = response.body_mut().as_reader().read_to_end(&mut body);
return Err(response_error(status, &body, path));
}
Ok(Box::new(response.into_body().into_reader()))
}
}
fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
ApiError {
message: format!(
"Couldn't reach the server at {base_url} ({e}) -- is ai-server running, and is this \
device able to reach that address (WireGuard up)? [{path}]"
),
status: None,
}
}
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
let detail = String::from_utf8_lossy(body).trim().to_string();
let message = if status == 401 {
"The server rejected this device's token. Re-enroll by scanning the server's QR (or \
rotate with --rotate-token and scan the new one)."
.to_string()
} else if detail.is_empty() {
format!("Server returned HTTP {status} for {path}")
} else {
detail
};
ApiError {
message,
status: Some(status),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use std::sync::Mutex;
#[derive(Default)]
struct FakeTransport {
responses: Mutex<Vec<(String, String, RawResponse)>>,
}
impl FakeTransport {
fn respond(&self, method: &str, path: &str, status: u16, body: &str) {
self.responses.lock().unwrap().push((
method.to_string(),
path.to_string(),
RawResponse {
status,
body: body.as_bytes().to_vec(),
},
));
}
}
impl Transport for FakeTransport {
fn request(
&self,
method: &str,
path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
let mut responses = self.responses.lock().unwrap();
let index = responses
.iter()
.position(|(m, p, _)| m == method && p == path)
.ok_or_else(|| ApiError {
message: format!("no fake response for {method} {path}"),
status: None,
})?;
let (_, _, response) = responses.remove(index);
if !(200..300).contains(&response.status) {
return Err(response_error(response.status, &response.body, path));
}
Ok(response)
}
fn stream(&self, _path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
Ok(Box::new(Cursor::new(Vec::new())))
}
}
#[test]
fn fetch_sessions_parses_the_list() {
let transport = FakeTransport::default();
transport.respond(
"GET",
"/sessions",
200,
r#"[{"id":"s1","setup":"m1","setupName":"desktop","provider":"claude_cli",
"title":"hi","status":"idle","lastActivity":1.0}]"#,
);
let client = ApiClient::new(transport);
let sessions = client.fetch_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s1");
assert_eq!(sessions[0].setup_name, "desktop");
assert!(sessions[0].notify);
assert_eq!(sessions[0].model, None);
}
#[test]
fn a_401_gets_the_enrollment_message_regardless_of_the_bare_body() {
let transport = FakeTransport::default();
transport.respond("POST", "/sessions/s1/interrupt", 401, "unauthorized");
let client = ApiClient::new(transport);
let err = client.interrupt_session("s1").unwrap_err();
assert!(err.message.contains("Re-enroll"));
assert_eq!(err.status, Some(401));
}
#[test]
fn a_bare_error_status_with_no_body_falls_back_to_a_generic_message() {
let transport = FakeTransport::default();
transport.respond("POST", "/sessions/s1/stop", 500, "");
let client = ApiClient::new(transport);
let err = client.stop_session("s1").unwrap_err();
assert!(err.message.contains("500"));
}
#[test]
fn a_server_explanation_in_the_body_is_surfaced_verbatim() {
let transport = FakeTransport::default();
transport.respond(
"POST",
"/sessions/s1/cwd",
409,
"that path does not exist on this machine",
);
let client = ApiClient::new(transport);
let err = client.set_session_cwd("s1", "/nope").unwrap_err();
assert_eq!(err.message, "that path does not exist on this machine");
}
}
+319
View File
@@ -0,0 +1,319 @@
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
/// because an app built on the machine its server runs on pins the CA at
/// build time and needs nothing from the link; one built elsewhere -- the
/// iris Android client is cross-compiled in a VM and run against the
/// host's server -- has no other way to get it. A public certificate
/// rather than a secret, so it costs the link nothing but length.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnrolledServer {
pub host: String,
pub port: u16,
pub token: String,
#[serde(default)]
pub ca_pem: Option<String>,
}
impl EnrolledServer {
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
/// here, because that is what every consumer of it wants
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
/// at). A `ca` that does not decode fails the whole link rather than
/// enrolling a server with no trust anchor: the link said which
/// certificate to pin, and quietly not pinning it is the one outcome
/// nothing downstream could notice.
pub fn parse_link(link: &str) -> Result<Self, String> {
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
format!(
"'{link}' has no query string (expected \
aiapp://enroll?host=...&port=...&token=...)"
)
})?;
let mut host = None;
let mut port = None;
let mut token = None;
let mut ca = None;
for pair in query.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
let value = percent_decode(value);
match key {
"host" => host = Some(value),
"port" => port = Some(value),
"token" => token = Some(value),
"ca" => ca = Some(value),
_ => {}
}
}
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
let port: u16 = port_str
.parse()
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?;
Ok(Self {
host,
port,
token,
ca_pem,
})
}
pub fn base_url(&self) -> String {
format!("https://{}:{}", self.host, self.port)
}
}
fn pem_from_link_param(ca: &str) -> Result<String, String> {
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(ca.as_bytes())
.map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?;
let body = base64::engine::general_purpose::STANDARD.encode(&der);
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
for line in body.as_bytes().chunks(64) {
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
pem.push('\n');
}
pem.push_str("-----END CERTIFICATE-----\n");
Ok(pem)
}
/// Where one client keeps the enrollment it should not have to be told
/// about a second time. `dir` is the caller's, because that is the only
/// part that differs by platform -- see this module's doc.
pub struct EnrollmentStore {
dir: PathBuf,
}
impl EnrollmentStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn file(&self) -> PathBuf {
self.dir.join("enrollment.json")
}
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
let path = self.file();
let json = serde_json::to_vec_pretty(server)
.expect("EnrolledServer holds nothing that fails to serialise");
std::fs::write(&path, json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
let path = self.file();
match std::fs::read(&path) {
Ok(bytes) => {
let server = serde_json::from_slice(&bytes).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{} is not a valid enrollment ({e})", path.display()),
)
})?;
Ok(Some(server))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let Ok(byte) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
out.push(byte);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_host_port_and_token() {
let server =
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
.unwrap();
assert_eq!(
server,
EnrolledServer {
host: "127.0.0.1".to_string(),
port: 8547,
token: "abcDEF123".to_string(),
ca_pem: None,
}
);
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
}
#[test]
fn field_order_does_not_matter() {
let server =
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
.unwrap();
assert_eq!(server.host, "example.com");
assert_eq!(server.port, 443);
assert_eq!(server.token, "tok");
}
#[test]
fn a_percent_encoded_token_is_decoded() {
let server =
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
assert_eq!(server.token, "a+b/c");
}
#[test]
fn a_missing_field_is_named_in_the_error() {
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
assert!(
err.contains("token"),
"error should name the missing field: {err}"
);
}
#[test]
fn a_ca_in_the_link_comes_back_as_pem() {
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der);
let server =
EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}"))
.unwrap();
let pem = server.ca_pem.expect("the link carried a CA");
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}");
assert!(
pem.trim_end().ends_with("-----END CERTIFICATE-----"),
"{pem}"
);
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(
pem.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<String>()
)
.unwrap(),
der
);
}
#[test]
fn no_ca_parameter_is_none_not_an_error() {
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
assert_eq!(server.ca_pem, None);
}
#[test]
fn a_ca_that_does_not_decode_fails_the_link() {
let err =
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url")
.unwrap_err();
assert!(err.contains("ca"), "{err}");
}
#[test]
fn a_saved_enrollment_reads_back_the_same() {
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
let server = EnrolledServer {
host: "127.0.0.1".to_string(),
port: 8547,
token: "tok".to_string(),
ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()),
};
store.save(&server).unwrap();
assert_eq!(store.load().unwrap(), Some(server));
}
#[test]
fn nothing_saved_yet_is_none_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
}
#[test]
fn an_enrollment_without_a_ca_still_loads() {
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
std::fs::create_dir_all(dir.path()).unwrap();
std::fs::write(
dir.path().join("enrollment.json"),
br#"{"host":"h","port":1,"token":"t"}"#,
)
.unwrap();
assert_eq!(store.load().unwrap().unwrap().ca_pem, None);
}
#[test]
#[cfg(unix)]
fn the_saved_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
store
.save(&EnrolledServer {
host: "h".to_string(),
port: 1,
token: "t".to_string(),
ca_pem: None,
})
.unwrap();
let mode = std::fs::metadata(dir.path().join("enrollment.json"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn a_corrupt_file_is_named_in_the_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap();
let err = EnrollmentStore::new(dir.path()).load().unwrap_err();
assert!(err.to_string().contains("enrollment.json"));
}
#[test]
fn a_non_numeric_port_is_named_in_the_error() {
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
assert!(
err.contains("port"),
"error should name the offending field: {err}"
);
}
}
+74
View File
@@ -0,0 +1,74 @@
/// A tool's timeout arrives as `480000`, which nobody reads as eight
/// minutes. The rule has two halves, because a short span and a long one
/// are read for different things. Under a minute the question is "roughly
/// how long", so only the largest unit is shown and a fraction carries the
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
/// units are left out rather than written as zero.
pub fn format_millis(ms: i64) -> String {
if ms < 0 {
return format!("-{}", format_millis(-ms));
}
if ms < 1000 {
return format!("{ms}ms");
}
if ms < 60_000 {
let tenths = (ms + 50) / 100;
let (whole, rest) = (tenths / 10, tenths % 10);
return if rest == 0 {
format!("{whole}s")
} else {
format!("{whole}.{rest}s")
};
}
let seconds = ms / 1000;
[
("d", seconds / 86_400),
("h", seconds / 3600 % 24),
("m", seconds / 60 % 60),
("s", seconds % 60),
]
.iter()
.filter(|(_, n)| *n > 0)
.map(|(unit, n)| format!("{n}{unit}"))
.collect::<Vec<_>>()
.join(" ")
}
pub fn format_millis_text(text: &str) -> String {
match text.trim().parse::<i64>() {
Ok(ms) => format_millis(ms),
Err(_) => text.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn under_a_minute_is_the_largest_unit_alone() {
assert_eq!(format_millis(30), "30ms");
assert_eq!(format_millis(999), "999ms");
assert_eq!(format_millis(1000), "1s");
assert_eq!(format_millis(2500), "2.5s");
assert_eq!(format_millis(2460), "2.5s");
assert_eq!(format_millis(59_900), "59.9s");
}
#[test]
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
assert_eq!(format_millis(480_000), "8m");
assert_eq!(format_millis(60_000), "1m");
assert_eq!(format_millis(90_000), "1m 30s");
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
assert_eq!(format_millis(432_240_000), "5d 4m");
}
#[test]
fn only_a_whole_number_of_milliseconds_is_rewritten() {
assert_eq!(format_millis_text(" 480000 "), "8m");
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
assert_eq!(format_millis_text(""), "");
}
}
+125
View File
@@ -0,0 +1,125 @@
use std::io::{BufRead, BufReader};
use event_model::SeqEvent;
use crate::client::api::{ApiError, Transport};
use crate::client::sse::SseReader;
/// The frame name the server uses to say a cursor was too far behind to
/// continue from. Must match `send_backlog` in `server/src/routes.rs`.
const RESET_EVENT: &str = "reset";
/// One frame of a session's event stream, folded from the wire shape the
/// caller needs to act on -- mirroring what `EventStream.kt`'s three
/// callbacks were for, as a single enum instead, since Rust has no
/// equivalent of handing three closures to one blocking call.
pub enum StreamItem {
Open,
Reset,
Event { raw: String, event: SeqEvent },
}
/// Follows `/sessions/{id}/events?after={after}`, calling `on_item` for
/// each [`StreamItem`] until the connection drops or `on_item` asks to
/// stop (by returning `false`). Reconnecting -- with the last seq seen as
/// the new cursor -- is the caller's job, same as in the Kotlin version.
pub fn follow_session_events(
transport: &dyn Transport,
session_id: &str,
after: u64,
mut on_item: impl FnMut(StreamItem) -> bool,
) -> Result<(), ApiError> {
let path = format!("/sessions/{session_id}/events?after={after}");
let body = transport.stream(&path)?;
if !on_item(StreamItem::Open) {
return Ok(());
}
let mut lines = BufReader::new(body).lines();
let mut reader = SseReader::new();
while let Some(line) = lines.next().transpose().map_err(|e| ApiError {
message: format!("Can't reach the server -- retrying. ({e})"),
status: None,
})? {
let Some(frame) = reader.feed_line(&line) else {
continue;
};
if frame.name.as_deref() == Some(RESET_EVENT) {
if !on_item(StreamItem::Reset) {
return Ok(());
}
} else if !frame.data.is_empty() {
let event: SeqEvent = serde_json::from_str(&frame.data).map_err(|e| ApiError {
message: format!("The server sent an event this build couldn't parse: {e}"),
status: None,
})?;
if !on_item(StreamItem::Event {
raw: frame.data,
event,
}) {
return Ok(());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::api::{Body, RawResponse};
use std::io::Cursor;
struct FixtureTransport {
body: &'static str,
}
impl Transport for FixtureTransport {
fn request(
&self,
_method: &str,
_path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
unimplemented!("this fixture only serves a stream")
}
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec())))
}
}
#[test]
fn events_and_a_reset_frame_are_told_apart() {
let transport = FixtureTransport {
body: "event:reset\n\ndata:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
};
let mut items = Vec::new();
follow_session_events(&transport, "s1", 0, |item| {
items.push(match item {
StreamItem::Open => "open".to_string(),
StreamItem::Reset => "reset".to_string(),
StreamItem::Event { event, .. } => format!("event:{}", event.seq),
});
true
})
.unwrap();
assert_eq!(items, vec!["open", "reset", "event:1"]);
}
#[test]
fn the_caller_can_stop_early() {
let transport = FixtureTransport {
body: "data:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n\
data:{\"seq\":2,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
};
let mut count = 0;
follow_session_events(&transport, "s1", 0, |item| {
if matches!(item, StreamItem::Event { .. }) {
count += 1;
}
count < 1
})
.unwrap();
assert_eq!(count, 1);
}
}
+536
View File
@@ -0,0 +1,536 @@
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Language {
C,
Coffeescript,
Cpp,
Csharp,
Dart,
Fish,
Go,
Java,
Javascript,
Json,
Kotlin,
Markdown,
Perl,
Php,
Python,
Ron,
Ruby,
Rust,
Shell,
Swift,
Toml,
Typescript,
}
impl Language {
pub const ALL: [Language; 22] = [
Language::C,
Language::Coffeescript,
Language::Cpp,
Language::Csharp,
Language::Dart,
Language::Fish,
Language::Go,
Language::Java,
Language::Javascript,
Language::Json,
Language::Kotlin,
Language::Markdown,
Language::Perl,
Language::Php,
Language::Python,
Language::Ron,
Language::Ruby,
Language::Rust,
Language::Shell,
Language::Swift,
Language::Toml,
Language::Typescript,
];
}
#[derive(Debug, Clone, Default)]
pub struct Rules {
pub keywords: HashSet<&'static str>,
pub line_comments: Vec<&'static str>,
pub line_comments_at_word_start: bool,
pub block_comment: Option<BlockComment>,
pub quotes: Vec<Quote>,
pub attributes: Attributes,
pub raw_strings: bool,
/// Rust: `'` opens a character literal only when a backslash or one
/// character and a `'` follow. Otherwise it is a lifetime or a label.
pub lifetimes: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct BlockComment {
pub open: &'static str,
pub close: &'static str,
pub nests: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct Quote {
pub open: &'static str,
pub close: &'static str,
pub escapes: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Attributes {
#[default]
None,
AtWord,
HashBracket,
HashLine,
LineBracket,
}
const C_STYLE: BlockComment = BlockComment {
open: "/*",
close: "*/",
nests: false,
};
const NESTING: BlockComment = BlockComment {
open: "/*",
close: "*/",
nests: true,
};
const DOUBLE: Quote = Quote {
open: "\"",
close: "\"",
escapes: true,
};
const SINGLE: Quote = Quote {
open: "'",
close: "'",
escapes: true,
};
const TRIPLE_DOUBLE: Quote = Quote {
open: "\"\"\"",
close: "\"\"\"",
escapes: true,
};
const TRIPLE_SINGLE: Quote = Quote {
open: "'''",
close: "'''",
escapes: true,
};
fn words(list: &'static str) -> HashSet<&'static str> {
list.split_whitespace().collect()
}
pub fn rules_for(language: Language) -> Rules {
match language {
Language::C => Rules {
keywords: words(KEYWORDS_C),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::HashLine,
..Default::default()
},
Language::Cpp => Rules {
keywords: words(KEYWORDS_CPP),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::HashLine,
..Default::default()
},
Language::Csharp => Rules {
keywords: words(KEYWORDS_CSHARP),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
Language::Coffeescript => Rules {
keywords: words(KEYWORDS_COFFEESCRIPT),
line_comments: vec!["#"],
block_comment: Some(BlockComment {
open: "###",
close: "###",
nests: false,
}),
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
..Default::default()
},
Language::Dart => Rules {
keywords: words(KEYWORDS_DART),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Fish => Rules {
keywords: words(KEYWORDS_FISH),
line_comments: vec!["#"],
line_comments_at_word_start: true,
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
Language::Go => Rules {
keywords: words(KEYWORDS_GO),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![
DOUBLE,
SINGLE,
Quote {
open: "`",
close: "`",
escapes: false,
},
],
..Default::default()
},
Language::Java => Rules {
keywords: words(KEYWORDS_JAVA),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Javascript => Rules {
keywords: words(KEYWORDS_JAVASCRIPT),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![
DOUBLE,
SINGLE,
Quote {
open: "`",
close: "`",
escapes: true,
},
],
..Default::default()
},
Language::Json => Rules {
keywords: words(KEYWORDS_JSON),
quotes: vec![DOUBLE],
..Default::default()
},
Language::Kotlin => Rules {
keywords: words(KEYWORDS_KOTLIN),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![
Quote {
open: "\"\"\"",
close: "\"\"\"",
escapes: false,
},
DOUBLE,
SINGLE,
],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Perl => Rules {
keywords: words(KEYWORDS_PERL),
line_comments: vec!["#"],
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
Language::Php => Rules {
keywords: words(KEYWORDS_PHP),
line_comments: vec!["//", "#"],
block_comment: Some(C_STYLE),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Python => Rules {
keywords: words(KEYWORDS_PYTHON),
line_comments: vec!["#"],
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Ron => Rules {
keywords: words(KEYWORDS_RON),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![DOUBLE, SINGLE],
attributes: Attributes::HashBracket,
raw_strings: true,
..Default::default()
},
Language::Ruby => Rules {
keywords: words(KEYWORDS_RUBY),
line_comments: vec!["#"],
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
Language::Rust => Rules {
keywords: words(KEYWORDS_RUST),
line_comments: vec!["//"],
block_comment: Some(NESTING),
// No `'` here: `lifetimes` decides when one opens a character literal.
quotes: vec![DOUBLE],
attributes: Attributes::HashBracket,
raw_strings: true,
lifetimes: true,
..Default::default()
},
Language::Shell => Rules {
keywords: words(KEYWORDS_SHELL),
line_comments: vec!["#"],
line_comments_at_word_start: true,
quotes: vec![
DOUBLE,
Quote {
open: "'",
close: "'",
escapes: false,
},
],
..Default::default()
},
Language::Swift => Rules {
keywords: words(KEYWORDS_SWIFT),
line_comments: vec!["//"],
block_comment: Some(NESTING),
quotes: vec![TRIPLE_DOUBLE, DOUBLE],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Toml => Rules {
keywords: words(KEYWORDS_TOML),
line_comments: vec!["#"],
quotes: vec![
TRIPLE_DOUBLE,
Quote {
open: "'''",
close: "'''",
escapes: false,
},
DOUBLE,
Quote {
open: "'",
close: "'",
escapes: false,
},
],
attributes: Attributes::LineBracket,
..Default::default()
},
Language::Typescript => Rules {
keywords: words(KEYWORDS_TYPESCRIPT),
line_comments: vec!["//"],
block_comment: Some(C_STYLE),
quotes: vec![
DOUBLE,
SINGLE,
Quote {
open: "`",
close: "`",
escapes: true,
},
],
attributes: Attributes::AtWord,
..Default::default()
},
Language::Markdown => Rules::default(),
}
}
const KEYWORDS_C: &str =
"auto break case char const continue default do double else enum extern float for goto if
int long register return short signed sizeof static struct switch typedef union unsigned
void volatile while";
const KEYWORDS_CPP: &str =
"asm auto bool break case catch char class const const_cast continue default delete do
double dynamic_cast else enum explicit export extern false float for friend goto if inline
int long mutable namespace new operator private protected public register reinterpret_cast
return short signed sizeof static static_cast struct switch template this throw true try
typedef typeid typename union unsigned using virtual void volatile wchar_t while";
const KEYWORDS_CSHARP: &str =
"abstract as base bool break byte case catch char checked class const continue decimal
default delegate do double else enum event explicit extern false finally fixed float for
foreach goto if implicit in int interface internal is lock long namespace new null object
operator out override params private protected public readonly ref return sbyte sealed short
sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked
unsafe ushort using virtual void volatile while";
const KEYWORDS_COFFEESCRIPT: &str =
"Infinity NaN and arguments await break by case catch class continue debugger delete defer
default do else export extends false finally for function if import in instanceof is isnt
let loop new no not null of on or package return super switch this throw true try typeof
unless undefined var wait when with yield";
const KEYWORDS_DART: &str =
"abstract as assert async await base break case catch class const continue covariant
default deferred do dynamic else enum export extends external factory false final finally
for get if implements import in interface is late library mixin new null on operator part
required rethrow return sealed set show static super switch this throw true try var void
when with while yield";
const KEYWORDS_FISH: &str =
"and begin break builtin case command continue else end exec for function if in not or
return switch while set echo test string math read source";
const KEYWORDS_GO: &str =
"break case chan const continue default defer else fallthrough false for func go goto if
import interface map package range return select struct switch true type var";
const KEYWORDS_JAVA: &str =
"abstract assert boolean break byte case catch char class const continue default do double
else enum extends final finally float for goto if implements import instanceof int interface
long native new null package private protected public return short static strictfp super
switch synchronized this throw throws transient try void volatile while";
const KEYWORDS_JAVASCRIPT: &str =
"async await boolean break case catch class const continue debugger default delete do else
enum export extends false finally for function if implements import in instanceof interface
let new null package private protected public return super switch this throw true try typeof
var void while with yield";
const KEYWORDS_JSON: &str = "true false null";
const KEYWORDS_KOTLIN: &str =
"actual abstract annotation as break by catch class companion const constructor continue
coroutine crossinline data delegate dynamic do else enum expect external false final finally
for fun get if import in infix inline interface internal is lazy lateinit native null object
open operator out override package private protected public reified return sealed set super
suspend tailrec this throw true try typealias typeof val var vararg when while yield";
const KEYWORDS_PERL: &str =
"__DATA__ __END__ __FILE__ __LINE__ __PACKAGE__ and cmp continue do else elsif eq eval for
foreach goto gt if last le lt my ne next no not or package redo ref return sub unless until
use while xor";
const KEYWORDS_PHP: &str =
"__halt_compiler abstract and array as break callable case catch class clone const continue
declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch
endwhile eval exit extends final finally fn for foreach function global goto if implements
include include_once instanceof insteadof interface isset list match new or print private
protected public require require_once return static switch throw trait try unset use var
while xor yield";
const KEYWORDS_PYTHON: &str =
"False True and as assert async await break class continue def del elif else except finally
for from global if import in is lambda nonlocal not or pass raise return try while with
yield";
const KEYWORDS_RON: &str = "true false Some None inf NaN";
const KEYWORDS_RUBY: &str =
"__ENCODING__ __END__ __FILE__ __LINE__ BEGIN END alias and begin break case class def do
else elsif end ensure false for if in module next nil not or redo rescue retry return self
super then true undef unless until when while yield";
const KEYWORDS_RUST: &str =
"as async await break const continue crate dyn else enum extern false fn for if impl in
let loop match mod move mut pub ref return Self self static struct super trait true type
union unsafe use where while abstract become box do final macro override priv try typeof
unsized virtual yield";
const KEYWORDS_SHELL: &str =
"alias bg bind break builtin caller cd command compgen complete compopt continue declare
dirs disown echo enable eval exec exit export fc fg getopts hash help history jobs kill let
local logout popd printf pushd pwd read readonly return set shift shopt source suspend
test";
const KEYWORDS_SWIFT: &str =
"_ associatedtype class deinit enum extension fileprivate func import init inout internal
let open operator private precedencegroup protocol public rethrows static struct subscript
typealias var break case catch continue default defer do else fallthrough for guard if in
repeat return throw switch where while Any as await false is nil self Self super throws true
try associativity convenience didSet dynamic final get indirect infix lazy left mutating none
nonmutating optional override postfix precedence prefix Protocol required right set some Type
unowned weak willSet";
const KEYWORDS_TOML: &str = "true false inf nan";
const KEYWORDS_TYPESCRIPT: &str =
"abstract as asserts await break case catch class const constructor continue debugger
default delete do else enum export extends false finally for from function get if implements
import in infer instanceof interface is keyof let module namespace new null number object
package private protected public readonly require global return set static string super
switch this throw true try type typeof undefined unique unknown var void while with yield";
/// The highlighter's language for a fence's info word, or `None` for one it
/// has no rules for. Also what `super::file_language` reads for a file's
/// extension -- one table, so a language added for fences is a language
/// added for files.
pub fn fence_language(name: Option<&str>) -> Option<Language> {
let name = name?.trim().to_lowercase();
FENCE_LANGUAGES
.iter()
.find(|(alias, _)| *alias == name)
.map(|(_, language)| *language)
}
/// The extension is the part after the *last* dot, which is what makes
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
/// extension, it has a name that starts with a dot. A name with no dot at
/// all -- `Makefile` -- is likewise `None`.
pub fn file_language(name: &str) -> Option<Language> {
let dot = name.rfind('.')?;
if dot < 1 {
return None;
}
fence_language(Some(&name[dot + 1..]))
}
const FENCE_LANGUAGES: &[(&str, Language)] = &[
("kotlin", Language::Kotlin),
("kt", Language::Kotlin),
("kts", Language::Kotlin),
("rust", Language::Rust),
("rs", Language::Rust),
("sh", Language::Shell),
("bash", Language::Shell),
("shell", Language::Shell),
("zsh", Language::Shell),
("console", Language::Shell),
("python", Language::Python),
("py", Language::Python),
("javascript", Language::Javascript),
("js", Language::Javascript),
("jsx", Language::Javascript),
("typescript", Language::Typescript),
("ts", Language::Typescript),
("tsx", Language::Typescript),
("java", Language::Java),
("c", Language::C),
("h", Language::C),
("cpp", Language::Cpp),
("c++", Language::Cpp),
("cc", Language::Cpp),
("hpp", Language::Cpp),
("csharp", Language::Csharp),
("cs", Language::Csharp),
("c#", Language::Csharp),
("go", Language::Go),
("golang", Language::Go),
("swift", Language::Swift),
("dart", Language::Dart),
("ruby", Language::Ruby),
("rb", Language::Ruby),
("php", Language::Php),
("perl", Language::Perl),
("pl", Language::Perl),
("coffeescript", Language::Coffeescript),
("coffee", Language::Coffeescript),
("ron", Language::Ron),
("toml", Language::Toml),
("fish", Language::Fish),
("json", Language::Json),
("markdown", Language::Markdown),
("md", Language::Markdown),
];
+618
View File
@@ -0,0 +1,618 @@
use super::{Kind, Span};
const BULLETS: &str = "-*+";
const RULE_MARKERS: &str = "-*_=";
const EMPHASIS: &str = "*_~";
const URL_STOPS: &str = "<>\"'`|";
const URL_TRAILING: &str = ".,:;!?";
pub fn scan_markdown(code: &str) -> Vec<Span> {
MarkdownScanner::new(code).run()
}
struct MarkdownScanner {
code: Vec<char>,
spans: Vec<Span>,
}
impl MarkdownScanner {
fn new(code: &str) -> Self {
Self {
code: code.chars().collect(),
spans: Vec::new(),
}
}
fn run(mut self) -> Vec<Span> {
let mut at = 0usize;
// The delimiter run that opened the fenced block we are inside, or
// None between them.
let mut fence: Option<Vec<char>> = None;
let mut table = false;
loop {
let end = self.line_end(at);
if let Some(open) = fence.clone() {
self.emit(at, end, Kind::String);
if self.closes_fence(at, end, &open) {
fence = None;
}
} else {
let opened = self.opens_fence(at, end);
if opened.is_some() {
table = false;
fence = opened;
} else {
table = self.row(at, end, table);
}
}
if end == self.code.len() {
break;
}
at = end + 1;
}
self.spans
}
fn line_end(&self, at: usize) -> usize {
self.code[at..]
.iter()
.position(|&c| c == '\n')
.map(|p| at + p)
.unwrap_or(self.code.len())
}
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
if self.table_delimiter(start, end) {
let indented = self.indented(start, end);
self.emit(indented, end, Kind::Mark);
return true;
}
let header = end < self.code.len() && self.table_delimiter(end + 1, self.line_end(end + 1));
if (table || header) && self.has_pipe(start, end) {
self.table_row(start, end);
return true;
}
self.structure(start, end);
false
}
fn table_delimiter(&self, start: usize, end: usize) -> bool {
let mut dashes = false;
let mut pipes = false;
for c in &self.code[self.indented(start, end)..end] {
match c {
'-' => dashes = true,
'|' => pipes = true,
':' | ' ' | '\t' => {}
_ => return false,
}
}
dashes && pipes
}
fn has_pipe(&self, start: usize, end: usize) -> bool {
let mut at = start;
while at < end {
if self.code[at] == '\\' {
at += 2;
} else if self.code[at] == '|' {
return true;
} else {
at += 1;
}
}
false
}
fn table_row(&mut self, start: usize, end: usize) {
let mut at = self.indented(start, end);
let mut cell = at;
while at < end {
match self.code[at] {
'\\' => at += 2,
'|' => {
self.inline(cell, at);
self.emit(at, at + 1, Kind::Mark);
at += 1;
cell = at;
}
_ => at += 1,
}
}
self.inline(cell, end);
}
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
if end <= start {
return;
}
if let Some(last) = self.spans.last_mut()
&& last.kind == kind
&& last.end == start
{
last.end = end;
return;
}
self.spans.push(Span { start, end, kind });
}
fn indented(&self, start: usize, end: usize) -> usize {
let mut at = start;
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
at += 1;
}
at
}
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
let at = self.indented(start, end);
if at == end {
return None;
}
let marker = self.code[at];
if marker != '`' && marker != '~' {
return None;
}
let mut run = at;
while run < end && self.code[run] == marker {
run += 1;
}
if run - at >= 3 { Some((at, run)) } else { None }
}
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
let (run_start, run_end) = self.fence_run(start, end)?;
self.emit(run_start, run_end, Kind::String);
let indented = self.indented(run_end, end);
self.emit(indented, end, Kind::Metadata);
Some(self.code[run_start..run_end].to_vec())
}
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
let Some((run_start, run_end)) = self.fence_run(start, end) else {
return false;
};
if self.code[run_start] != open[0] || run_end - run_start < open.len() {
return false;
}
self.indented(run_end, end) == end
}
fn structure(&mut self, start: usize, end: usize) {
let mut at = start;
while at < end && self.code[at] == '>' {
at += 1;
self.emit(at - 1, at, Kind::Mark);
at = self.indented(at, end);
}
if at == end {
return;
}
if self.heading(at, end) || self.thematic_break(at, end) {
return;
}
let text_start = self.bullet(at, end);
self.inline(text_start, end);
}
fn heading(&mut self, start: usize, end: usize) -> bool {
let mut at = start;
while at < end && self.code[at] == '#' {
at += 1;
}
let depth = at - start;
if !(1..=6).contains(&depth) {
return false;
}
if at < end && self.code[at] != ' ' && self.code[at] != '\t' {
return false;
}
self.emit(start, end, Kind::Keyword);
true
}
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
let marker = self.code[start];
if !RULE_MARKERS.contains(marker) {
return false;
}
let mut seen = 0usize;
for &c in &self.code[start..end] {
if c == marker {
seen += 1;
} else if !c.is_whitespace() {
return false;
}
}
if seen < if marker == '=' { 1 } else { 3 } {
return false;
}
self.emit(start, end, Kind::Mark);
true
}
fn bullet(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start];
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
self.emit(start, start + 1, Kind::Mark);
return self.indented(start + 1, end);
}
let mut digits = start;
while digits < end && self.code[digits].is_ascii_digit() {
digits += 1;
}
let delimiter = self.code.get(digits).copied();
if digits > start
&& (delimiter == Some('.') || delimiter == Some(')'))
&& self.space_or_end(digits + 1, end)
{
self.emit(start, digits + 1, Kind::Mark);
return self.indented(digits + 1, end);
}
start
}
fn space_or_end(&self, at: usize, end: usize) -> bool {
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
}
fn inline(&mut self, start: usize, end: usize) {
let mut at = start;
while at < end {
let c = self.code[at];
at = if c == '\\' {
at + 2
} else if c == '`' {
self.code_span(at, end)
} else if c == '[' {
self.link(at, at, end)
} else if c == '!' && self.code.get(at + 1) == Some(&'[') {
self.link(at, at + 1, end)
} else if c == '<' {
self.autolink(at, end)
} else if EMPHASIS.contains(c) {
self.emphasis(at, end)
} else {
self.url(at, end).unwrap_or(at + 1)
};
}
}
fn code_span(&mut self, start: usize, end: usize) -> usize {
let mut open = start;
while open < end && self.code[open] == '`' {
open += 1;
}
let ticks = open - start;
let mut at = open;
while at < end {
if self.code[at] != '`' {
at += 1;
continue;
}
let mut close = at;
while close < end && self.code[close] == '`' {
close += 1;
}
if close - at == ticks {
self.emit(start, close, Kind::String);
return close;
}
at = close;
}
open
}
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
let mut depth = 0i32;
let mut close = bracket;
while close < end {
match self.code[close] {
'\\' => close += 1,
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
close += 1;
}
if close >= end {
return start + 1;
}
let destination = close + 1;
if self.code.get(destination) != Some(&'(') {
return start + 1;
}
let Some(paren_rel) = self.code[destination..].iter().position(|&c| c == ')') else {
return start + 1;
};
let paren = destination + paren_rel;
if paren >= end {
return start + 1;
}
self.emit(start, bracket + 1, Kind::Mark);
self.inline(bracket + 1, close);
self.emit(close, destination, Kind::Mark);
self.emit(destination, paren + 1, Kind::Metadata);
paren + 1
}
fn autolink(&mut self, start: usize, end: usize) -> usize {
let mut at = start + 1;
let mut addressed = false;
while at < end {
let c = self.code[at];
if c.is_whitespace() || c == '<' {
return start + 1;
}
if c == '>' {
if !addressed {
return start + 1;
}
self.emit(start, at + 1, Kind::Metadata);
return at + 1;
}
if c == ':' || c == '@' {
addressed = true;
}
at += 1;
}
start + 1
}
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
if start > 0 && is_word(self.code[start - 1]) {
return None;
}
let mut scheme = start;
while scheme < end && self.code[scheme].is_alphabetic() {
scheme += 1;
}
if scheme == start || !starts_with(&self.code, scheme, "://") {
return None;
}
let body = scheme + 3;
let mut at = body;
let mut openers = 0i32;
let mut closers = 0i32;
while at < end && !self.code[at].is_whitespace() && !URL_STOPS.contains(self.code[at]) {
if self.code[at] == '(' {
openers += 1;
} else if self.code[at] == ')' {
closers += 1;
}
at += 1;
}
while at > body {
let last = self.code[at - 1];
if URL_TRAILING.contains(last) {
at -= 1;
} else if last == ')' && closers > openers {
closers -= 1;
at -= 1;
} else {
break;
}
}
if at == body {
return None;
}
self.emit(start, at, Kind::Metadata);
Some(at)
}
fn emphasis(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start];
let mut open = start;
while open < end && self.code[open] == marker {
open += 1;
}
let length = open - start;
if marker == '~' && length != 2 {
return open;
}
if length > 3 {
return open;
}
if open == end || self.code[open].is_whitespace() {
return open;
}
if marker == '_' && start > 0 && is_word(self.code[start - 1]) {
return open;
}
let mut at = open;
while at < end {
if self.code[at] == '\\' {
at += 2;
continue;
}
if self.code[at] != marker {
at += 1;
continue;
}
let mut close = at;
while close < end && self.code[close] == marker {
close += 1;
}
let finish = at + length;
if close - at >= length
&& !self.code[at - 1].is_whitespace()
&& !(marker == '_' && finish < end && is_word(self.code[finish]))
{
self.emit(start, finish, Kind::Literal);
return finish;
}
at = close;
}
open
}
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn starts_with(code: &[char], at: usize, token: &str) -> bool {
let token: Vec<char> = token.chars().collect();
if at + token.len() > code.len() {
return false;
}
code[at..at + token.len()] == token[..]
}
#[cfg(test)]
mod tests {
use super::super::{Kind, Language, span_text, spans_of};
fn spans(code: &str, kind: Kind) -> Vec<String> {
let chars: Vec<char> = code.chars().collect();
spans_of(code, Language::Markdown)
.into_iter()
.filter(|s| s.kind == kind)
.map(|s| span_text(&chars, &s))
.collect()
}
fn assert_spans(code: &str, kind: Kind, expected: &[&str]) {
assert_eq!(spans(code, kind), expected.to_vec(), "{kind:?} in: {code}");
}
#[test]
fn a_heading_is_coloured_whole_and_a_hash_inside_a_word_is_not_one() {
let code = "## Layout\nissue #12 is fixed\n#hashtag";
assert_spans(code, Kind::Keyword, &["## Layout"]);
}
#[test]
fn seven_hashes_are_not_a_heading() {
assert_spans("####### deep", Kind::Keyword, &[]);
}
#[test]
fn a_fence_carries_its_language_as_metadata_and_its_body_as_one_string() {
let code = "text\n```kotlin\nval x = 1\n```\nmore";
assert_spans(code, Kind::Metadata, &["kotlin"]);
assert_spans(code, Kind::String, &["```", "val x = 1", "```"]);
}
#[test]
fn a_longer_fence_is_not_closed_by_a_shorter_one_and_a_heading_inside_it_is_not_a_heading() {
let code = "````\n```\n# not a heading\n````\nafter";
assert_spans(code, Kind::Keyword, &[]);
assert_spans(
code,
Kind::String,
&["````", "```", "# not a heading", "````"],
);
}
#[test]
fn an_unclosed_fence_runs_to_the_end_rather_than_panicking() {
assert_spans("```\nstill going", Kind::String, &["```", "still going"]);
}
#[test]
fn list_markers_and_quote_markers_colour_without_their_text() {
let code = "- one\n2. two\n> quoted";
assert_spans(code, Kind::Mark, &["-", "2.", ">"]);
}
#[test]
fn a_rule_and_a_setext_underline_are_the_same_mark() {
assert_spans("Title\n=====\n\n---", Kind::Mark, &["=====", "---"]);
}
#[test]
fn emphasis_needs_something_on_both_sides_of_it() {
assert_spans(
"**bold** and *thin*",
Kind::Literal,
&["**bold**", "*thin*"],
);
assert_spans("a * b * c and *p = *q", Kind::Literal, &[]);
}
#[test]
fn an_underscore_inside_a_word_emphasises_nothing() {
assert_spans("snake_case_name and _real_", Kind::Literal, &["_real_"]);
}
#[test]
fn a_code_span_holds_a_backtick_when_opened_with_two() {
assert_spans("``a ` b`` and `c`", Kind::String, &["``a ` b``", "`c`"]);
}
#[test]
fn an_unclosed_code_span_is_ordinary_text() {
assert_spans("a ` b", Kind::String, &[]);
}
#[test]
fn a_link_marks_its_brackets_and_colours_its_destination() {
let code = "see [the plan](PLAN.md) now";
assert_spans(code, Kind::Mark, &["[", "]"]);
assert_spans(code, Kind::Metadata, &["(PLAN.md)"]);
}
#[test]
fn a_table_is_found_by_its_delimiter_row_and_pipes_elsewhere_are_plain() {
let code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph";
assert_spans(
code,
Kind::Mark,
&["|", "|", "|", "|---|---|", "|", "|", "|"],
);
}
#[test]
fn a_table_without_outer_pipes_still_colours_and_the_table_ends_with_the_rows() {
let code = "a | b\n--- | ---\nnot a row";
assert_spans(code, Kind::Mark, &["|", "--- | ---"]);
}
#[test]
fn an_autolink_colours_and_an_html_tag_does_not() {
let code = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">";
assert_spans(
code,
Kind::Metadata,
&["<https://example.com>", "<a@b.com>", "http://x"],
);
}
#[test]
fn a_bare_url_gives_back_the_sentences_punctuation() {
assert_spans(
"see https://example.com/a., and ssh://host/x)",
Kind::Metadata,
&["https://example.com/a", "ssh://host/x"],
);
}
#[test]
fn a_bracket_a_url_opened_itself_stays_in_it() {
assert_spans(
"https://en.wikipedia.org/wiki/A_(b) here",
Kind::Metadata,
&["https://en.wikipedia.org/wiki/A_(b)"],
);
}
#[test]
fn a_url_inside_a_link_destination_is_not_coloured_twice() {
assert_spans(
"[x](https://example.com)",
Kind::Metadata,
&["(https://example.com)"],
);
}
#[test]
fn a_bracket_with_no_destination_after_it_is_left_plain() {
assert_spans("an [aside] here", Kind::Mark, &[]);
}
}
+654
View File
@@ -0,0 +1,654 @@
pub mod languages;
pub mod markdown;
pub use languages::{
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind {
Keyword,
String,
Literal,
Comment,
Metadata,
Punctuation,
Mark,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start: usize,
pub end: usize,
pub kind: Kind,
}
/// The text a [`Span`] covers, for a caller working in char indices (every
/// test in this module, and any UI that also holds `code` as `Vec<char>`).
pub fn span_text(code: &[char], span: &Span) -> String {
code[span.start..span.end].iter().collect()
}
/// The spans `language` colours in `code` -- the one way to ask, whatever
/// the language turns out to be made of. `None` draws plain.
pub fn spans_of(code: &str, language: Language) -> Vec<Span> {
if language == Language::Markdown {
markdown::scan_markdown(code)
} else {
scan(code, &rules_for(language))
}
}
/// `code` read into the spans [`Rules`] describes. Also reachable directly
/// for a caller that already has a [`Rules`] (there is currently only one:
/// [`spans_of`]), kept public because the Kotlin original exposed it the
/// same way.
pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
Scanner::new(code, rules).run()
}
const PUNCTUATION: &str = ",.:;";
const MARKS: &str = "()={}<>-+[]|&";
struct Scanner<'a> {
code: Vec<char>,
rules: &'a Rules,
spans: Vec<Span>,
at: usize,
}
impl<'a> Scanner<'a> {
fn new(code: &str, rules: &'a Rules) -> Self {
Self {
code: code.chars().collect(),
rules,
spans: Vec::new(),
at: 0,
}
}
fn run(mut self) -> Vec<Span> {
while self.at < self.code.len() {
let consumed = self.block_comment()
|| self.line_comment()
|| self.raw_string()
|| self.character_or_lifetime()
|| self.string()
|| self.attribute()
|| self.number()
|| self.word()
|| self.single_character();
if !consumed {
self.at += 1;
}
}
self.spans
}
fn emit(&mut self, start: usize, kind: Kind) {
if self.at > start {
self.spans.push(Span {
start,
end: self.at,
kind,
});
}
}
fn starts(&self, token: &str) -> bool {
starts_with_at(&self.code, self.at, token)
}
fn at_word_start(&self) -> bool {
self.at == 0
|| self.code[self.at - 1].is_whitespace()
|| ";|&(".contains(self.code[self.at - 1])
}
fn at_line_start(&self) -> bool {
let mut back = self.at as isize - 1;
while back >= 0 && self.code[back as usize] != '\n' {
if !self.code[back as usize].is_whitespace() {
return false;
}
back -= 1;
}
true
}
fn advance_to_end_of_line(&mut self) {
while self.at < self.code.len() && self.code[self.at] != '\n' {
self.at += 1;
}
}
fn advance_to_matching_bracket(&mut self) {
let mut depth = 0i32;
while self.at < self.code.len() {
match self.code[self.at] {
'[' => depth += 1,
']' => depth -= 1,
_ => {}
}
self.at += 1;
if depth == 0 {
return;
}
}
}
fn block_comment(&mut self) -> bool {
let Some(comment) = self.rules.block_comment else {
return false;
};
if !self.starts(comment.open) {
return false;
}
let start = self.at;
self.at += comment.open.chars().count();
let mut depth = 1i32;
while self.at < self.code.len() && depth > 0 {
if self.starts(comment.close) {
depth -= 1;
self.at += comment.close.chars().count();
} else if comment.nests && self.starts(comment.open) {
depth += 1;
self.at += comment.open.chars().count();
} else {
self.at += 1;
}
}
self.emit(start, Kind::Comment);
true
}
fn line_comment(&mut self) -> bool {
if !self.rules.line_comments.iter().any(|c| self.starts(c)) {
return false;
}
if self.rules.line_comments_at_word_start && !self.at_word_start() {
return false;
}
let start = self.at;
self.advance_to_end_of_line();
self.emit(start, Kind::Comment);
true
}
fn raw_string(&mut self) -> bool {
if !self.rules.raw_strings {
return false;
}
let mut ahead = self.at;
if self.code.get(ahead) == Some(&'b') {
ahead += 1;
}
if self.code.get(ahead) != Some(&'r') {
return false;
}
ahead += 1;
let mut hashes = 0usize;
while self.code.get(ahead) == Some(&'#') {
ahead += 1;
hashes += 1;
}
if self.code.get(ahead) != Some(&'"') {
return false;
}
let start = self.at;
let closer: String = std::iter::once('"')
.chain(std::iter::repeat_n('#', hashes))
.collect();
let closer_chars: Vec<char> = closer.chars().collect();
let closed = find_from(&self.code, ahead + 1, &closer_chars);
self.at = match closed {
Some(index) => index + closer_chars.len(),
None => self.code.len(),
};
self.emit(start, Kind::String);
true
}
/// See [`Rules::lifetimes`]: an apostrophe that is not a character
/// literal opens nothing.
fn character_or_lifetime(&mut self) -> bool {
if !self.rules.lifetimes || self.code[self.at] != '\'' {
return false;
}
let Some(&next) = self.code.get(self.at + 1) else {
return false;
};
if next == '\\' || self.code.get(self.at + 2) == Some(&'\'') {
self.quoted(Quote {
open: "'",
close: "'",
escapes: true,
});
} else {
self.at += 1;
}
true
}
fn string(&mut self) -> bool {
let mut quote: Option<Quote> = None;
for candidate in &self.rules.quotes {
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
if self.starts(candidate.open) && candidate.open.chars().count() > current_len {
quote = Some(*candidate);
}
}
let Some(quote) = quote else {
return false;
};
self.quoted(quote);
true
}
fn quoted(&mut self, quote: Quote) {
let start = self.at;
self.at += quote.open.chars().count();
while self.at < self.code.len() {
if quote.escapes && self.code[self.at] == '\\' && self.at + 1 < self.code.len() {
self.at += 2;
continue;
}
if self.starts(quote.close) {
self.at += quote.close.chars().count();
break;
}
self.at += 1;
}
self.at = self.at.min(self.code.len());
self.emit(start, Kind::String);
}
fn attribute(&mut self) -> bool {
let start = self.at;
match self.rules.attributes {
Attributes::None => return false,
Attributes::AtWord => {
if self.code[self.at] != '@' || !is_word_start(self.code.get(self.at + 1).copied())
{
return false;
}
self.at += 1;
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
self.at += 1;
}
}
Attributes::HashBracket => {
if self.code[self.at] != '#' {
return false;
}
let mut ahead = self.at + 1;
if self.code.get(ahead) == Some(&'!') {
ahead += 1;
}
if self.code.get(ahead) != Some(&'[') {
return false;
}
self.at = ahead;
self.advance_to_matching_bracket();
}
Attributes::HashLine => {
if self.code[self.at] != '#' || !self.at_line_start() {
return false;
}
self.advance_to_end_of_line();
}
Attributes::LineBracket => {
if self.code[self.at] != '[' || !self.at_line_start() {
return false;
}
self.advance_to_matching_bracket();
}
}
self.emit(start, Kind::Metadata);
true
}
fn number(&mut self) -> bool {
if !self.code[self.at].is_ascii_digit() {
return false;
}
let start = self.at;
while self.at < self.code.len() {
let c = self.code[self.at];
if c.is_alphanumeric() || c == '_' || c == '.' {
self.at += 1;
} else {
break;
}
}
self.emit(start, Kind::Literal);
true
}
fn word(&mut self) -> bool {
if !is_word_start(Some(self.code[self.at])) {
return false;
}
let start = self.at;
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
self.at += 1;
}
let word: String = self.code[start..self.at].iter().collect();
if self.rules.keywords.contains(word.as_str()) {
self.emit(start, Kind::Keyword);
}
true
}
fn single_character(&mut self) -> bool {
let kind = if PUNCTUATION.contains(self.code[self.at]) {
Kind::Punctuation
} else if MARKS.contains(self.code[self.at]) {
Kind::Mark
} else {
return false;
};
self.at += 1;
self.emit(self.at - 1, kind);
true
}
}
fn is_word_start(c: Option<char>) -> bool {
matches!(c, Some(c) if c.is_alphabetic() || c == '_')
}
fn is_word_part(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
let token: Vec<char> = token.chars().collect();
if at + token.len() > code.len() {
return false;
}
code[at..at + token.len()] == token[..]
}
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
if needle.is_empty() || from > code.len() {
return None;
}
(from..=code.len().saturating_sub(needle.len())).find(|&i| code[i..i + needle.len()] == *needle)
}
#[cfg(test)]
mod tests {
use super::*;
fn spans(code: &str, language: Language, kind: Kind) -> Vec<String> {
let chars: Vec<char> = code.chars().collect();
spans_of(code, language)
.into_iter()
.filter(|s| s.kind == kind)
.map(|s| span_text(&chars, &s))
.collect()
}
fn assert_spans(code: &str, language: Language, kind: Kind, expected: &[&str]) {
assert_eq!(
spans(code, language, kind),
expected.to_vec(),
"{kind:?} in: {code}"
);
}
#[test]
fn a_quoted_glob_is_one_string_not_a_comment() {
assert_spans("x '*/a/*'", Language::Shell, Kind::String, &["'*/a/*'"]);
assert_spans("x '*/a/*'", Language::Shell, Kind::Comment, &[]);
}
#[test]
fn a_find_with_globs_has_no_comment_in_it() {
let code = "find . -path '*/.git/*' -prune -o -name '*.kt' -print";
assert_spans(
code,
Language::Shell,
Kind::String,
&["'*/.git/*'", "'*.kt'"],
);
assert_spans(code, Language::Shell, Kind::Comment, &[]);
}
#[test]
fn a_url_does_not_comment_out_the_rest_of_a_shell_line() {
let code = "curl https://example.com/x && echo done";
assert_spans(code, Language::Shell, Kind::Comment, &[]);
assert_spans(code, Language::Shell, Kind::Keyword, &["echo"]);
}
#[test]
fn a_url_inside_a_kotlin_string_stays_a_string() {
let code = "val url = \"https://example.com\"\nfun f() = 1";
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
assert_spans(
code,
Language::Kotlin,
Kind::String,
&["\"https://example.com\""],
);
assert_spans(code, Language::Kotlin, Kind::Keyword, &["val", "fun"]);
}
#[test]
fn a_rust_attribute_is_metadata_and_the_struct_after_it_still_colours() {
let code = "#[derive(Debug)]\nstruct A { b: u8 }";
assert_spans(code, Language::Rust, Kind::Metadata, &["#[derive(Debug)]"]);
assert_spans(code, Language::Rust, Kind::Comment, &[]);
assert_spans(code, Language::Rust, Kind::Keyword, &["struct"]);
}
#[test]
fn an_inner_rust_attribute_closes_at_its_own_bracket() {
let code = "#![allow(dead_code)]\nfn f() {}";
assert_spans(
code,
Language::Rust,
Kind::Metadata,
&["#![allow(dead_code)]"],
);
assert_spans(code, Language::Rust, Kind::Keyword, &["fn"]);
}
#[test]
fn a_c_preprocessor_line_is_metadata_rather_than_a_comment() {
let code = "#include <stdio.h>\nint main() { return 0; }";
assert_spans(code, Language::C, Kind::Metadata, &["#include <stdio.h>"]);
assert_spans(code, Language::C, Kind::Comment, &[]);
assert_spans(code, Language::C, Kind::Keyword, &["int", "return"]);
}
#[test]
fn a_kotlin_annotation_is_metadata() {
assert_spans(
"@Composable fun f() {}",
Language::Kotlin,
Kind::Metadata,
&["@Composable"],
);
}
#[test]
fn a_hash_inside_a_kotlin_string_is_not_a_comment() {
let code = "val c = \"#FF0000\"\nval d = 1";
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
assert_spans(code, Language::Kotlin, Kind::String, &["\"#FF0000\""]);
}
#[test]
fn an_apostrophe_inside_a_kotlin_string_does_not_open_one() {
let code = "val a = \"don't\"\nval b = \"x\"";
assert_spans(
code,
Language::Kotlin,
Kind::String,
&["\"don't\"", "\"x\""],
);
}
#[test]
fn a_rust_lifetime_does_not_open_a_string_but_a_character_literal_does() {
let code = "fn f<'a>(x: &'a str) { let c = 'x'; }";
assert_spans(code, Language::Rust, Kind::String, &["'x'"]);
}
#[test]
fn an_escaped_quote_is_inside_the_rust_character_literal() {
assert_spans("let c = '\\'';", Language::Rust, Kind::String, &["'\\''"]);
}
#[test]
fn a_rust_raw_string_keeps_its_inner_quotes() {
let code = "let s = r#\"a \"quoted\" b\"#;";
assert_spans(
code,
Language::Rust,
Kind::String,
&["r#\"a \"quoted\" b\"#"],
);
}
#[test]
fn a_kotlin_triple_quoted_string_is_one_string() {
assert_spans(
"val s = \"\"\"a \"b\" c\"\"\"",
Language::Kotlin,
Kind::String,
&["\"\"\"a \"b\" c\"\"\""],
);
}
#[test]
fn a_shell_single_quoted_string_takes_no_escapes() {
assert_spans("echo 'a\\' b", Language::Shell, Kind::String, &["'a\\'"]);
}
#[test]
fn rust_and_kotlin_nest_block_comments() {
let code = "/* a /* b */ c */ x";
assert_spans(code, Language::Rust, Kind::Comment, &["/* a /* b */ c */"]);
assert_spans(
code,
Language::Kotlin,
Kind::Comment,
&["/* a /* b */ c */"],
);
}
#[test]
fn c_ends_a_block_comment_at_the_first_close() {
assert_spans(
"/* a /* b */ c */ x",
Language::C,
Kind::Comment,
&["/* a /* b */"],
);
}
#[test]
fn a_shell_comment_starts_only_at_a_word_boundary() {
let code = "${#x} $# a#b # real";
assert_spans(code, Language::Shell, Kind::Comment, &["# real"]);
}
#[test]
fn a_hash_anywhere_is_a_python_comment() {
assert_spans("x = 1 # note", Language::Python, Kind::Comment, &["# note"]);
}
#[test]
fn a_toml_table_header_is_metadata_and_a_hash_in_a_value_is_not_a_comment() {
let code = "[server]\ncolour = \"#FF0000\"\nport = 8080 # the real one";
assert_spans(code, Language::Toml, Kind::Metadata, &["[server]"]);
assert_spans(code, Language::Toml, Kind::String, &["\"#FF0000\""]);
assert_spans(code, Language::Toml, Kind::Comment, &["# the real one"]);
assert_spans(code, Language::Toml, Kind::Literal, &["8080"]);
}
#[test]
fn a_ron_attribute_and_its_values_colour() {
let code = "#![enable(implicit_some)]\n(count: 3, on: true)";
assert_spans(
code,
Language::Ron,
Kind::Metadata,
&["#![enable(implicit_some)]"],
);
assert_spans(code, Language::Ron, Kind::Keyword, &["true"]);
assert_spans(code, Language::Ron, Kind::Literal, &["3"]);
}
#[test]
fn an_unknown_fence_language_is_none() {
assert_eq!(fence_language(Some("brainfuck")), None);
}
#[test]
fn every_language_the_fence_table_knows_has_a_scanner() {
for language in Language::ALL {
spans_of("x", language);
}
}
#[test]
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
let nasty = [
"",
"'",
"\"",
"\"unterminated",
"/* unterminated",
"###",
"#",
"#![",
"[",
"r#\"",
"\\",
"'''",
"\"\"\"",
"0x",
"1.2.3",
"a#b//c/*d*/'e\"f",
"```",
"*",
"**",
"~~",
"> ",
"- ",
"1.",
"[x](",
"#######",
"|",
"|---|",
"<",
"<>",
"http://",
"a://",
"\n\n \n",
];
for language in Language::ALL {
for code in nasty {
let chars: Vec<char> = code.chars().collect();
let spans = spans_of(code, language);
for s in &spans {
assert!(
s.start <= s.end && s.end <= chars.len(),
"{language:?} answered {s:?} for {code:?}"
);
}
let mut sorted = spans.clone();
sorted.sort_by_key(|s| s.start);
assert_eq!(
spans, sorted,
"{language:?} answered spans out of order for {code:?}"
);
}
}
}
}
+637
View File
@@ -0,0 +1,637 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// Both bounds apply -- whichever bites first -- because the two failure
/// modes are different: a flood of short lines exhausts the count, and one
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
/// only by bytes can be emptied by a single line.
pub const DEFAULT_MAX_LINES: usize = 2000;
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
pub seq: u64,
/// Milliseconds since the unix epoch, from the app's own clock. The
/// app's rather than the receiver's: a line is timestamped when it
/// happened, and an upload can be minutes later or never.
pub at_ms: u64,
pub level: log::Level,
pub target: String,
pub message: String,
}
impl LogLine {
fn weight(&self) -> usize {
self.target.len() + self.message.len() + 32
}
/// `12:34:56.789 INFO iris::android: the message`, the shape a
/// person skims. Time of day only -- the date is in the report's own
/// header, and a ring never spans one.
pub fn format(&self) -> String {
format!(
"{} {:<5} {}: {}",
clock_time(self.at_ms),
self.level,
self.target,
self.message
)
}
}
fn clock_time(at_ms: u64) -> String {
let ms = at_ms % 1000;
let secs_of_day = (at_ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
ms
)
}
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
/// the app down for.
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Debug)]
struct Inner {
lines: VecDeque<LogLine>,
bytes: usize,
max_lines: usize,
max_bytes: usize,
next_seq: u64,
dropped: u64,
}
#[derive(Debug, Clone)]
pub struct LogRing(Arc<Mutex<Inner>>);
impl LogRing {
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
assert!(
max_lines > 0 && max_bytes > 0,
"a ring with no room holds nothing"
);
Self(Arc::new(Mutex::new(Inner {
lines: VecDeque::new(),
bytes: 0,
max_lines,
max_bytes,
next_seq: 0,
dropped: 0,
})))
}
pub fn with_defaults() -> Self {
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
}
/// A poisoned lock is a bug in a panicking logger, not a reason to
/// take the app down a second time -- the ring is a diagnostic, and
/// losing it must not be worse than the fault it was recording.
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
let mut guard = match self.0.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
f(&mut guard)
}
pub fn push(&self, level: log::Level, target: &str, message: String) {
self.with(|inner| {
let line = LogLine {
seq: inner.next_seq,
at_ms: now_ms(),
level,
target: target.to_string(),
message,
};
inner.next_seq += 1;
inner.bytes += line.weight();
inner.lines.push_back(line);
// `!is_empty()` rather than `len() > 1`: one line larger than
// the whole byte bound is kept, because dropping it would
// leave the ring silently empty while lines were arriving.
while inner.lines.len() > inner.max_lines
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
{
if let Some(evicted) = inner.lines.pop_front() {
inner.bytes -= evicted.weight();
inner.dropped += 1;
}
}
})
}
pub fn snapshot(&self) -> Vec<LogLine> {
self.with(|inner| inner.lines.iter().cloned().collect())
}
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
self.with(|inner| {
let lines: Vec<LogLine> = inner
.lines
.iter()
.filter(|line| line.seq >= seq)
.cloned()
.collect();
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
(lines, next)
})
}
pub fn len(&self) -> usize {
self.with(|inner| inner.lines.len())
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn dropped(&self) -> u64 {
self.with(|inner| inner.dropped)
}
/// The sequence number of the newest line held, or `None` for a ring
/// nothing has been written to.
///
/// What a reader needs to notice that this process **restarted**: the
/// ring is in memory, so a new process starts again at zero, and a
/// reader holding a cursor from the previous one would otherwise ask
/// for lines after a number nothing will reach for hours and see
/// nothing at all -- silently, which is worse than seeing the log
/// begin again. Answering `None` rather than 0 for an empty ring is
/// the same distinction [`Self::summary`] draws: "nothing has been
/// logged" is not a sequence number.
pub fn newest_seq(&self) -> Option<u64> {
self.with(|inner| inner.lines.back().map(|line| line.seq))
}
/// When the newest line was written, in unix milliseconds, or `None`
/// for a ring nothing has been written to.
pub fn last_at_ms(&self) -> Option<u64> {
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
}
pub fn to_text(&self) -> String {
self.snapshot()
.iter()
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n")
}
/// The newest `max_lines` lines, formatted, or `None` if the ring is
/// locked at this instant.
///
/// For the one caller that must not block: **the panic hook**. A panic
/// raised while this ring's own lock was held -- an allocation failing
/// inside [`Self::push`], an assertion in a `log::Log` on the way here
/// -- would deadlock the hook against the thread that is panicking,
/// and the process would hang instead of aborting, with nothing
/// written anywhere. Losing the context lines is the right trade
/// against that, and `None` says which happened rather than looking
/// like an empty log.
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
let guard = match self.0.try_lock() {
Ok(guard) => guard,
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => return None,
};
let lines = &guard.lines;
let from = lines.len().saturating_sub(max_lines);
Some(
lines
.iter()
.skip(from)
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n"),
)
}
/// One line for a diagnostics pane: how much is held, how much was
/// dropped, and when the last line arrived. "no lines yet" is its own
/// wording rather than a count of zero with a made-up time, because
/// "nothing has been logged" and "logging is not running" would
/// otherwise look the same.
pub fn summary(&self) -> String {
let (len, dropped, last) = self.with(|inner| {
(
inner.lines.len(),
inner.dropped,
inner.lines.back().map(|line| line.at_ms),
)
});
match last {
None => "app log: no lines yet".to_string(),
Some(at) => {
let dropped = if dropped > 0 {
format!(", {dropped} dropped")
} else {
String::new()
};
format!(
"app log: {len} lines held{dropped}, last {}",
clock_time(at)
)
}
}
}
}
/// Whether a target belongs to this app or to `iris` rather than to a
/// dependency -- `starts_with` guarded by an
/// exact match or a `::` so an unrelated crate that merely begins with the
/// same letters (there is no such crate today, but the check should not
/// rely on that) is never mistaken for one of ours.
fn is_own_target(target: &str) -> bool {
target == "iris"
|| target.starts_with("iris::")
|| target == "ai_app"
|| target.starts_with("ai_app::")
}
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
/// asked for, applied once here rather than at each `debug!` call site:
/// Info and above always ring, from anything, because a real warning or
/// error from a dependency is worth keeping. Debug and Trace ring only
/// from this app's own targets, and only while tracing is switched on --
/// otherwise `naga::front`/`wgpu_core`/`jni` log at Debug unconditionally
/// (the process logger's own level, set once at install and unrelated to
/// tracing), which is what filled the ring with 1339 lines of it and
/// dropped 4050 more before this existed. `iris`'s own Debug lines already
/// self-gate on `iris::diagnostics::trace_enabled` at their call sites
/// (commit 992c472); this is the backstop for lines this crate does not
/// control.
fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
level <= log::Level::Info || (trace_enabled && is_own_target(target))
}
pub struct RingLogger {
ring: LogRing,
inner: Box<dyn log::Log>,
trace_enabled: fn() -> bool,
}
impl RingLogger {
pub fn new(ring: LogRing, inner: Box<dyn log::Log>, trace_enabled: fn() -> bool) -> Self {
Self {
ring,
inner,
trace_enabled,
}
}
}
impl log::Log for RingLogger {
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
if ring_accepts(record.level(), record.target(), (self.trace_enabled)()) {
self.ring
.push(record.level(), record.target(), record.args().to_string());
}
if self.inner.enabled(record.metadata()) {
self.inner.log(record);
}
}
fn flush(&self) {
self.inner.flush();
}
}
/// Fails only if a logger is already installed, which is a programmer
/// error (two initialisation paths) rather than a recoverable condition --
/// the caller is named in the error so it is findable.
pub fn install(
ring: LogRing,
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
trace_enabled: fn() -> bool,
) -> Result<(), log::SetLoggerError> {
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner, trace_enabled)))?;
log::set_max_level(max_level);
Ok(())
}
/// **A deliberate process-global, where this project's rules otherwise say
/// pass context explicitly.** What is being modelled is already one: `log`
/// has exactly one backend per process, set once, and every `log::info!`
/// anywhere in the binary goes to it. A ring handed around as a parameter
/// would be a *second* answer to "which lines exist" -- the report would
/// show one ring while the logger filled another, and which one a caller
/// got would depend on how far down the call tree it was. The tests above
/// all use their own [`LogRing`], so nothing here needs this to be
/// testable.
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
pub fn process_ring() -> &'static LogRing {
PROCESS_RING.get_or_init(LogRing::with_defaults)
}
pub fn install_process_logger(
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
trace_enabled: fn() -> bool,
) -> Result<(), log::SetLoggerError> {
install(process_ring().clone(), inner, max_level, trace_enabled)
}
#[cfg(test)]
mod tests {
use super::*;
use log::Level;
fn fill(ring: &LogRing, count: usize) {
for n in 0..count {
ring.push(Level::Info, "test", format!("line {n}"));
}
}
#[test]
fn lines_come_back_oldest_first() {
let ring = LogRing::new(10, 1 << 20);
fill(&ring, 3);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 0", "line 1", "line 2"]);
}
#[test]
fn the_line_bound_drops_the_oldest_and_says_how_many() {
let ring = LogRing::new(3, 1 << 20);
fill(&ring, 5);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
assert_eq!(ring.len(), 3);
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
}
#[test]
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
let ring = LogRing::new(1000, 300);
for n in 0..10 {
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
}
assert!(
ring.len() < 10,
"the byte bound evicted: {} held",
ring.len()
);
assert!(ring.dropped() > 0);
assert!(
ring.snapshot().last().unwrap().message.starts_with('9'),
"and it evicted from the old end"
);
}
#[test]
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
let ring = LogRing::new(100, 64);
ring.push(Level::Error, "t", "y".repeat(5000));
assert_eq!(ring.len(), 1);
assert_eq!(ring.dropped(), 0);
}
#[test]
fn sequence_numbers_only_increase_and_survive_eviction() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 5);
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
}
#[test]
fn since_returns_only_what_is_new_and_the_next_cursor() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 3);
let (first, cursor) = ring.since(0);
assert_eq!(first.len(), 3);
assert_eq!(cursor, 3);
let (none, cursor) = ring.since(cursor);
assert!(none.is_empty(), "nothing new yet");
assert_eq!(cursor, 3, "and the cursor does not move");
ring.push(Level::Warn, "test", "later".into());
let (more, cursor) = ring.since(cursor);
assert_eq!(more.len(), 1);
assert_eq!(more[0].message, "later");
assert_eq!(cursor, 4);
}
#[test]
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
let ring = LogRing::new(100, 1 << 20);
assert_eq!(ring.newest_seq(), None, "an empty ring has no newest line");
fill(&ring, 5);
assert_eq!(ring.newest_seq(), Some(4));
let restarted = LogRing::new(100, 1 << 20);
fill(&restarted, 1);
assert_eq!(
restarted.newest_seq(),
Some(0),
"a fresh ring starts again, which is exactly what a reader has to notice"
);
}
#[test]
fn reading_does_not_consume() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 2);
let (sent, _) = ring.since(0);
assert_eq!(sent.len(), 2);
assert_eq!(ring.len(), 2, "the report still has them after an upload");
assert_eq!(ring.to_text().lines().count(), 2);
}
#[test]
fn try_tail_text_gives_the_newest_lines_with_no_header() {
let ring = LogRing::new(1000, 1 << 20);
fill(&ring, 200);
let tail = ring.try_tail_text(80).expect("nothing holds the lock");
let lines: Vec<&str> = tail.lines().collect();
assert_eq!(lines.len(), 80, "the cap, and no header: this is a file");
assert!(lines[0].ends_with("line 120"), "{}", lines[0]);
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
}
#[test]
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
let ring = LogRing::new(10, 1 << 20);
fill(&ring, 3);
let held = ring.0.lock().expect("fresh ring");
assert_eq!(ring.try_tail_text(80), None);
drop(held);
assert!(ring.try_tail_text(80).is_some());
}
#[test]
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
let ring = LogRing::with_defaults();
assert_eq!(ring.summary(), "app log: no lines yet");
assert_eq!(ring.last_at_ms(), None);
assert!(ring.is_empty());
}
#[test]
fn the_summary_names_dropped_lines_only_when_there_are_some() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 2);
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
fill(&ring, 2);
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
}
#[test]
fn a_line_formats_as_time_level_target_message() {
let line = LogLine {
seq: 0,
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
level: Level::Info,
target: "iris::android".into(),
message: "surface created".into(),
}
.format();
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
}
#[test]
fn the_ring_logger_forwards_to_the_inner_logger() {
use log::Log;
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
impl Log for Collect {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= self.1
}
fn log(&self, record: &log::Record) {
self.0.lock().unwrap().push(record.args().to_string());
}
fn flush(&self) {}
}
let seen = Arc::new(Mutex::new(Vec::new()));
let ring = LogRing::with_defaults();
let logger = RingLogger::new(
ring.clone(),
Box::new(Collect(seen.clone(), Level::Info)),
|| true,
);
logger.log(
&log::Record::builder()
.args(format_args!("kept"))
.level(Level::Info)
.target("iris::test")
.build(),
);
logger.log(
&log::Record::builder()
.args(format_args!("filtered"))
.level(Level::Debug)
.target("iris::test")
.build(),
);
assert_eq!(
*seen.lock().unwrap(),
["kept"],
"the inner logger's own filter still applies"
);
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(
held,
["kept", "filtered"],
"own-target debug still rings while tracing is on"
);
}
#[test]
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
use log::Log;
struct Discard;
impl Log for Discard {
fn enabled(&self, _: &log::Metadata) -> bool {
true
}
fn log(&self, _: &log::Record) {}
fn flush(&self) {}
}
let ring = LogRing::with_defaults();
let logger = RingLogger::new(ring.clone(), Box::new(Discard), || true);
logger.log(
&log::Record::builder()
.args(format_args!("naga debug spam"))
.level(Level::Debug)
.target("naga::front")
.build(),
);
logger.log(
&log::Record::builder()
.args(format_args!("naga warning"))
.level(Level::Warn)
.target("wgpu_core::device")
.build(),
);
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(
held,
["naga warning"],
"Info-and-above always rings; foreign Debug never does"
);
}
#[test]
fn ring_accepts_is_own_target_debug_only_while_tracing() {
assert!(
ring_accepts(Level::Info, "wgpu_core::device", false),
"Info+ from anything, tracing off"
);
assert!(
ring_accepts(Level::Warn, "jni", true),
"Info+ from anything, tracing on"
);
assert!(
!ring_accepts(Level::Debug, "jni", true),
"foreign Debug, tracing on: still excluded"
);
assert!(
!ring_accepts(Level::Debug, "iris::sense", false),
"own Debug, tracing off: excluded"
);
assert!(
ring_accepts(Level::Debug, "iris::sense", true),
"own Debug, tracing on: included"
);
assert!(
ring_accepts(Level::Trace, "ai_app::api", true),
"own Trace, tracing on: included"
);
}
#[test]
fn is_own_target_matches_the_crate_or_its_modules_only() {
assert!(is_own_target("iris"));
assert!(is_own_target("iris::sense"));
assert!(is_own_target("ai_app"));
assert!(is_own_target("ai_app::log_ring"));
assert!(!is_own_target("iris_something_else"));
assert!(!is_own_target("naga::front"));
assert!(!is_own_target("jni"));
}
}
+241
View File
@@ -0,0 +1,241 @@
use pulldown_cmark::{Event, Options, Parser, Tag};
/// What a block is, for a renderer that wants to style or space blocks
/// differently. `Other` is deliberately present rather than a panic or a
/// silent fallback to `Paragraph`: markdown has more block kinds than this
/// list and more get added, and a renderer treating an unknown one as
/// prose is right, but it should be able to *tell* that is what it is
/// doing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockKind {
Paragraph,
Heading,
Code,
List,
Table,
Quote,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Block {
pub kind: BlockKind,
pub source: String,
}
fn kind_of(tag: &Tag) -> BlockKind {
match tag {
Tag::Paragraph => BlockKind::Paragraph,
Tag::Heading { .. } => BlockKind::Heading,
Tag::CodeBlock(_) => BlockKind::Code,
Tag::List(_) => BlockKind::List,
Tag::Table(_) => BlockKind::Table,
Tag::BlockQuote(_) => BlockKind::Quote,
_ => BlockKind::Other,
}
}
fn options() -> Options {
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
}
pub fn split_blocks(src: &str) -> Vec<Block> {
let mut out: Vec<Block> = Vec::new();
let mut depth = 0usize;
let mut kind = BlockKind::Other;
for (event, range) in Parser::new_ext(src, options()).into_offset_iter() {
match event {
Event::Start(tag) => {
if depth == 0 {
kind = kind_of(&tag);
}
depth += 1;
}
Event::End(_) => {
depth -= 1;
if depth == 0 {
push(&mut out, kind, &src[range]);
}
}
_ => {
if depth == 0 {
push(&mut out, BlockKind::Other, &src[range]);
}
}
}
}
out
}
fn push(out: &mut Vec<Block>, kind: BlockKind, source: &str) {
let source = source.trim_end();
if source.is_empty() {
return;
}
out.push(Block {
kind,
source: source.to_string(),
});
}
/// How many leading blocks of `old` and `new` are identical -- what a
/// caller may keep the laid-out widgets for. See the module doc for why
/// this is a comparison rather than an assumption.
pub fn common_prefix(old: &[Block], new: &[Block]) -> usize {
old.iter().zip(new).take_while(|(a, b)| a == b).count()
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(src: &str) -> Vec<BlockKind> {
split_blocks(src).into_iter().map(|b| b.kind).collect()
}
#[test]
fn a_message_splits_into_its_top_level_blocks() {
let src = "# Title\n\nFirst para.\n\n```rust\nfn main() {}\n```\n\n- a\n- b\n";
assert_eq!(
kinds(src),
vec![
BlockKind::Heading,
BlockKind::Paragraph,
BlockKind::Code,
BlockKind::List
]
);
let blocks = split_blocks(src);
assert_eq!(blocks[1].source, "First para.");
assert_eq!(blocks[2].source, "```rust\nfn main() {}\n```");
}
#[test]
fn blank_input_has_no_blocks() {
assert!(split_blocks("").is_empty());
assert!(split_blocks(" \n\n ").is_empty());
}
#[test]
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
let after = split_blocks("# Title\n\nFirst para.\n\nSecond paragraph now.");
assert_eq!(common_prefix(&before, &after), 2);
assert_eq!(before.len(), 3);
assert_eq!(after.len(), 3);
assert_ne!(before[2], after[2]);
}
#[test]
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
let before = split_blocks("First para.\n\nSecond para.");
let after = split_blocks("First para.\n\nSecond para.\n\nThird");
assert_eq!(common_prefix(&before, &after), 2);
assert_eq!(after.len(), 3);
}
#[test]
fn an_unterminated_fence_is_one_block_while_it_streams() {
for src in [
"Here:\n\n```rust\n",
"Here:\n\n```rust\nfn main() {\n",
"Here:\n\n```rust\nfn main() {\n println!(\"hi\");\n",
] {
assert_eq!(
kinds(src),
vec![BlockKind::Paragraph, BlockKind::Code],
"{src:?}"
);
}
}
#[test]
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
let before = split_blocks("Not a heading\n\nsecond");
let after = split_blocks("Not a heading\n\nsecond\n---");
assert_eq!(before[1].kind, BlockKind::Paragraph);
assert_eq!(after[1].kind, BlockKind::Heading);
assert_eq!(
common_prefix(&before, &after),
1,
"the rewritten block must not be reported as keepable"
);
}
#[test]
fn a_thematic_break_is_its_own_block() {
assert_eq!(
kinds("one\n\n---\n\ntwo"),
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
);
}
#[test]
fn the_transcripts_own_block_shapes_survive_a_split() {
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
assert_eq!(
kinds(fence_with_blanks),
vec![BlockKind::Paragraph, BlockKind::Code, BlockKind::Paragraph],
"a blank line inside a fence is not a block boundary"
);
assert_eq!(
kinds("```\n---\n```"),
vec![BlockKind::Code],
"a thematic break inside a fence is code, not a break"
);
assert_eq!(
kinds("- a\n - a1\n - a2\n- b"),
vec![BlockKind::List],
"a nested list is one top-level block"
);
assert_eq!(
kinds("## Heading\n```sh\nls\n```"),
vec![BlockKind::Heading, BlockKind::Code],
"a fence directly under a heading, with no blank line"
);
assert_eq!(
kinds("| a | b |\n|---|---|\n| 1 | 2 |"),
vec![BlockKind::Table]
);
assert_eq!(
kinds("> quoted\n> more\n\nplain"),
vec![BlockKind::Quote, BlockKind::Paragraph]
);
}
#[test]
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
let mut prev = Vec::new();
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
let now = split_blocks(&full[..end]);
let common = common_prefix(&prev, &now);
assert!(
prev.is_empty() || common + 1 >= prev.len(),
"at {end} bytes the split rewrote block {common} of {}, not just the last one:\n before={prev:#?}\nafter={now:#?}",
prev.len()
);
prev = now;
}
}
#[test]
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
let blocks = split_blocks(src);
assert_eq!(
blocks.iter().map(|b| b.kind).collect::<Vec<_>>(),
vec![BlockKind::Paragraph, BlockKind::Code]
);
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
}
#[test]
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
let before = split_blocks("Text.\n\n```\ncode\n");
let after = split_blocks("Text.\n\n```\ncode\n```");
assert_eq!(before.len(), after.len());
assert_eq!(common_prefix(&before, &after), 1);
assert_ne!(before[1], after[1]);
}
}
+17
View File
@@ -0,0 +1,17 @@
pub mod ansi;
pub mod api;
pub mod config;
pub mod durations;
pub mod event_stream;
pub mod highlight;
pub mod log_ring;
pub mod markdown_blocks;
pub mod notifications;
pub mod sse;
pub mod text_cap;
pub mod tool_summary;
pub mod transcript_cache;
pub mod transcript_fold;
pub mod transcript_source;
pub use event_model::*;
+149
View File
@@ -0,0 +1,149 @@
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
//! places, never both" describes. Ported from the parsing half of
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
//! ([`crate::client::sse`]) and the wire shape ([`SessionNotification`],
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
//! `Notification`/`NotificationKind`).
use std::io::{BufRead, BufReader};
use serde::Deserialize;
use crate::client::api::{ApiError, Transport};
use crate::client::sse::SseReader;
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNotification {
pub session_id: String,
pub title: String,
pub kind: NotificationKind,
/// Epoch seconds, so a phone that was asleep can say how long ago.
pub at: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NotificationKind {
AwaitingInput,
Finished,
}
impl NotificationKind {
/// What a notification asks of the reader, in the words they see --
/// ported verbatim from `Notifications.kt`'s `attentionLine`. One
/// function because the same fact is shown in two places (the
/// platform's drawer and the app's own banner) and two mappings of one
/// word drift.
pub fn attention_line(self) -> &'static str {
match self {
NotificationKind::AwaitingInput => "Waiting for you",
NotificationKind::Finished => "Finished",
}
}
}
/// Follows `/notifications`, calling `on_notification` for each frame until
/// the connection drops or the callback asks to stop (by returning
/// `false`). Reconnecting is the caller's job -- mirroring
/// `NotificationService.follow`'s retry loop, which is a platform policy
/// (how long to wait, whether to give up) rather than parsing logic.
pub fn follow_notifications(
transport: &dyn Transport,
mut on_notification: impl FnMut(SessionNotification) -> bool,
) -> Result<(), ApiError> {
let body = transport.stream("/notifications")?;
let mut lines = BufReader::new(body).lines();
let mut reader = SseReader::new();
while let Some(line) = lines.next().transpose().map_err(|e| ApiError {
message: format!("Can't reach the server -- retrying. ({e})"),
status: None,
})? {
let Some(frame) = reader.feed_line(&line) else {
continue;
};
if frame.data.is_empty() {
continue;
}
let notification: SessionNotification =
serde_json::from_str(&frame.data).map_err(|e| ApiError {
message: format!("The server sent a notification this build couldn't parse: {e}"),
status: None,
})?;
if !on_notification(notification) {
return Ok(());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::api::{Body, RawResponse};
use std::io::Cursor;
struct FixtureTransport {
body: &'static str,
}
impl Transport for FixtureTransport {
fn request(
&self,
_method: &str,
_path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
unimplemented!("this fixture only serves a stream")
}
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec())))
}
}
#[test]
fn a_notification_frame_parses_both_kinds() {
let transport = FixtureTransport {
body: "data:{\"sessionId\":\"s1\",\"title\":\"fix the bug\",\"kind\":\"awaitingInput\",\"at\":1.0}\n\n\
data:{\"sessionId\":\"s2\",\"title\":\"add tests\",\"kind\":\"finished\",\"at\":2.0}\n\n",
};
let mut seen = Vec::new();
follow_notifications(&transport, |n| {
seen.push((n.session_id, n.kind));
true
})
.unwrap();
assert_eq!(
seen,
vec![
("s1".to_string(), NotificationKind::AwaitingInput),
("s2".to_string(), NotificationKind::Finished),
]
);
}
#[test]
fn the_caller_can_stop_early() {
let transport = FixtureTransport {
body: "data:{\"sessionId\":\"s1\",\"title\":\"a\",\"kind\":\"finished\",\"at\":1.0}\n\n\
data:{\"sessionId\":\"s2\",\"title\":\"b\",\"kind\":\"finished\",\"at\":2.0}\n\n",
};
let mut count = 0;
follow_notifications(&transport, |_| {
count += 1;
count < 1
})
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn attention_line_matches_the_kotlin_original() {
assert_eq!(
NotificationKind::AwaitingInput.attention_line(),
"Waiting for you"
);
assert_eq!(NotificationKind::Finished.attention_line(), "Finished");
}
}
+102
View File
@@ -0,0 +1,102 @@
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub name: Option<String>,
pub data: String,
}
#[derive(Debug, Default)]
pub struct SseReader {
data: String,
name: Option<String>,
}
impl SseReader {
pub fn new() -> Self {
Self::default()
}
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
if line.is_empty() {
if self.name.is_some() || !self.data.is_empty() {
let frame = Frame {
name: self.name.take(),
data: std::mem::take(&mut self.data),
};
return Some(frame);
}
return None;
}
if let Some(rest) = line.strip_prefix("data:") {
self.data.push_str(rest.trim());
} else if let Some(rest) = line.strip_prefix("event:") {
self.name = Some(rest.trim().to_string());
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frames(lines: &[&str]) -> Vec<Frame> {
let mut reader = SseReader::new();
lines.iter().filter_map(|l| reader.feed_line(l)).collect()
}
#[test]
fn a_data_only_frame_has_no_name() {
assert_eq!(
frames(&["data:hello", ""]),
vec![Frame {
name: None,
data: "hello".to_string()
}]
);
}
#[test]
fn a_named_frame_with_no_payload_still_completes() {
assert_eq!(
frames(&["event:reset", ""]),
vec![Frame {
name: Some("reset".to_string()),
data: String::new()
}]
);
}
#[test]
fn a_blank_line_with_nothing_pending_yields_no_frame() {
assert_eq!(frames(&[""]), vec![]);
}
#[test]
fn a_comment_and_an_id_line_are_ignored() {
assert_eq!(
frames(&[":keepalive", "id:5", "data:hi", ""]),
vec![Frame {
name: None,
data: "hi".to_string()
}]
);
}
#[test]
fn two_frames_in_a_row_are_both_reported() {
assert_eq!(
frames(&["data:one", "", "data:two", ""]),
vec![
Frame {
name: None,
data: "one".to_string()
},
Frame {
name: None,
data: "two".to_string()
},
]
);
}
}
+93
View File
@@ -0,0 +1,93 @@
/// The default bound on a verbatim block -- a tool call's input or its
/// output. Short, because this text is a machine's and the reader is
/// looking for one line of it.
pub const VERBATIM_LINES: usize = 80;
pub const VERBATIM_BYTES: usize = 4096;
pub const MESSAGE_LINES: usize = 200;
pub const MESSAGE_BYTES: usize = 16 * 1024;
const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0);
const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0);
/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line
/// count it was cut *from*; `None` when the whole of it fits.
pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> {
debug_assert!(
max_lines > 0 && max_bytes > 0,
"a cap of nothing shows an empty block and a 'Show all' for every value there is",
);
let by_lines = text
.char_indices()
.filter(|(_, c)| *c == '\n')
.nth(max_lines - 1)
.map(|(i, _)| i);
let by_bytes = (text.len() > max_bytes).then(|| {
let mut end = max_bytes;
// Back up to a character boundary: a cut inside a multi-byte
// character panics on the slice below, and a transcript is full of
// them.
while !text.is_char_boundary(end) {
end -= 1;
}
end
});
let cut = match (by_lines, by_bytes) {
(Some(a), Some(b)) => a.min(b),
(a, b) => a.or(b)?,
};
Some((&text[..cut], text.lines().count()))
}
pub fn show_all_label(lines: usize) -> String {
format!("Show all {lines} lines")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_under_both_bounds_is_not_cut() {
assert_eq!(cut("one\ntwo\nthree", 80, 4096), None);
}
#[test]
fn the_line_bound_cuts_at_a_line_boundary() {
let text = "a\nb\nc\nd\n";
let (shown, lines) = cut(text, 2, 4096).expect("four lines is over a bound of two");
assert_eq!(shown, "a\nb");
assert_eq!(
lines, 4,
"the count is the whole text's, not the shown part's"
);
}
#[test]
fn the_byte_bound_cuts_one_long_line() {
let text = "x".repeat(5000);
let (shown, lines) = cut(&text, 80, 4096).expect("5000 bytes is over a bound of 4096");
assert_eq!(shown.len(), 4096);
assert_eq!(lines, 1);
}
#[test]
fn the_tighter_of_the_two_bounds_wins() {
let text = "aaaa\n".repeat(100);
let (shown, _) = cut(&text, 80, 100).expect("over both");
assert_eq!(shown.len(), 100, "the byte bound is the tighter one here");
let (shown, _) = cut(&text, 4, 4096).expect("over the line bound");
assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa");
}
#[test]
fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() {
let text = "é".repeat(100);
let (shown, _) = cut(&text, 80, 11).expect("200 bytes is over a bound of 11");
assert_eq!(
shown,
"é".repeat(5),
"11 bytes lands mid-character; 10 is the cut"
);
}
}
+186
View File
@@ -0,0 +1,186 @@
use crate::client::durations::format_millis_text;
use crate::client::highlight::Language;
use serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ToolInput {
pub subject: Option<String>,
pub language: Option<Language>,
pub description: Option<String>,
/// How long the call may take, in the largest units it fits. Shown
/// apart because it is a limit on the call rather than part of what
/// the call does.
pub timeout: Option<String>,
/// Everything else, as `name: value` lines. Never dropped.
pub rest: Vec<String>,
}
impl ToolInput {
pub fn title(&self) -> Option<&str> {
self.description
.as_deref()
.or(self.subject.as_deref())
.filter(|t| !t.trim().is_empty())
}
}
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
("Bash", "command", Some(Language::Shell)),
("Read", "file_path", None),
("Write", "file_path", None),
("Edit", "file_path", None),
("Glob", "pattern", None),
("Grep", "pattern", None),
("WebFetch", "url", None),
];
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
/// One function rather than two, because the same coercion decides both
/// what a subject reads as and what a leftover field's value reads as, and
/// two copies would eventually disagree about a number.
fn as_text(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn non_blank(value: Option<&Value>) -> Option<String> {
let text = as_text(value?);
(!text.trim().is_empty()).then_some(text)
}
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
return ToolInput {
rest: match input.trim().is_empty() {
true => Vec::new(),
false => vec![input.to_string()],
},
..ToolInput::default()
};
};
parse_object(tool, &json)
}
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
let (subject_key, language) = SUBJECTS
.iter()
.find(|(name, ..)| *name == tool)
.map(|(_, key, language)| (Some(*key), *language))
.unwrap_or((None, None));
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
let description = DESCRIPTIONS
.iter()
.find_map(|key| non_blank(json.get(*key)));
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
let mut keys: Vec<&String> = json
.keys()
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
.collect();
keys.sort();
let rest = keys
.into_iter()
.map(|key| format!("{key}: {}", as_text(&json[key])))
.collect();
ToolInput {
subject,
language,
description,
timeout,
rest,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_tool_in_the_table_has_its_own_subject() {
let cases = [
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
];
for (tool, input, expected) in cases {
let parsed = parse_tool_input(tool, input);
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
assert_eq!(parsed.title(), Some(expected), "{tool}");
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
}
assert_eq!(
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
Some(Language::Shell),
"a Bash command is shell, and is the one row that names a language"
);
}
#[test]
fn a_tools_own_description_is_what_the_one_line_says() {
let parsed = parse_tool_input(
"Bash",
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
);
assert_eq!(parsed.title(), Some("Run the iris tests"));
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
}
#[test]
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
}
#[test]
fn every_field_not_drawn_elsewhere_is_still_shown() {
let parsed = parse_tool_input(
"Edit",
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
);
assert_eq!(
parsed.rest,
vec![
"new_string: y".to_string(),
"old_string: x".to_string(),
"replace_all: true".to_string(),
],
"sorted, and a non-string value written as JSON"
);
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
assert_eq!(unknown.subject, None);
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
}
#[test]
fn input_that_is_not_an_object_is_still_the_input() {
assert_eq!(
parse_tool_input("Bash", "just a string").rest,
vec!["just a string".to_string()]
);
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
assert_eq!(parse_tool_input("Bash", "").title(), None);
}
#[test]
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
assert_eq!(parsed.subject, None);
assert_eq!(parsed.title(), None);
assert_eq!(
parsed.rest,
vec!["command: ".to_string(), "other: 1".to_string()]
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+479
View File
@@ -0,0 +1,479 @@
use event_model::SeqEvent;
use crate::client::api::{ApiClient, ApiError, Transport};
use crate::client::event_stream::{self, StreamItem};
use crate::client::transcript_cache::SessionCache;
/// The server's own default page size, named here because the cached
/// opening has to be the same size as the fetched one -- a reader must not
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
/// in the Kotlin original).
pub const OPENING_WINDOW: u32 = 80;
/// A transcript-line parse failure, told apart from [`ApiError`] so a
/// caller can tell "the server is unreachable" from "the server (or this
/// phone's own disk) sent something this build cannot read" -- the two
/// mean different things to a reader (retry, versus a build that is
/// behind).
#[derive(Debug, Clone)]
pub struct ParseError(pub String);
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone)]
pub enum PageError {
Api(ApiError),
Parse(ParseError),
}
impl From<ApiError> for PageError {
fn from(e: ApiError) -> Self {
Self::Api(e)
}
}
impl From<ParseError> for PageError {
fn from(e: ParseError) -> Self {
Self::Parse(e)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum OlderPage {
Events(Vec<SeqEvent>),
NothingLoaded,
}
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
}
pub struct TranscriptSource<T: Transport> {
api: ApiClient<T>,
session_id: String,
pub cache: SessionCache,
}
impl<T: Transport> TranscriptSource<T> {
pub fn new(api: ApiClient<T>, session_id: impl Into<String>, cache: SessionCache) -> Self {
Self {
api,
session_id: session_id.into(),
cache,
}
}
/// The cached opening window, or `None` when there is nothing usable
/// to draw.
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
self.cache.tail()?;
let lines = self.cache.newest(limit);
if lines.is_empty() {
return None;
}
match lines.iter().map(|l| parse_line(l)).collect() {
Ok(events) => Some(events),
Err(ParseError(_)) => {
self.cache.purge();
None
}
}
}
/// A caller must not resume a live stream from a cached seq unless it
/// is the same conversation: a transcript is append-only in ordinary
/// use, but the file backing it can be replaced or truncated (a
/// sandbox re-seeded with the same ids, a backup restored, a session
/// re-imported), and the server's catch-up on such a file would hand
/// this phone a continuation of a *different* conversation, spliced
/// onto the cached one with no seam. Caught with one request of a few
/// hundred bytes.
///
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
/// server not being askable, which is neither: the cached rows stay
/// on screen and the caller tries again on its own reconnect schedule.
pub fn probe(&self) -> Result<bool, ApiError> {
let Some(tail) = self.cache.tail() else {
return Ok(false);
};
let page = self.api.fetch_transcript_lines(
&self.session_id,
Some(tail.seq + 1),
1,
false,
None,
)?;
let matches = page.len() == 1
&& parse_line(&tail.line)
.map(|cached| cached == page[0].1)
.unwrap_or(false);
if !matches {
self.cache.purge();
}
Ok(matches)
}
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
let page =
self.api
.fetch_transcript_lines(&self.session_id, None, OPENING_WINDOW, false, None)?;
for (line, event) in &page {
self.cache.append(line, event.seq);
}
self.cache.flush();
Ok(page.into_iter().map(|(_, event)| event).collect())
}
/// The page before `before`: from the cache when it holds it,
/// otherwise from the server bounded by what the cache already has.
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
if before == 0 {
return Ok(OlderPage::NothingLoaded);
}
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
let events: Vec<SeqEvent> = lines
.iter()
.map(|l| parse_line(l).map_err(PageError::from))
.collect::<Result<_, _>>()?;
return Ok(OlderPage::Events(events));
}
let after = self.cache.covered_up_to(before).map(|v| v - 1);
let page = self.api.fetch_transcript_lines(
&self.session_id,
Some(before),
limit,
coalesce,
after,
)?;
if let Some((_, first_event)) = page.first() {
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
self.cache
.store_page(&lines, first_event.seq, before, coalesce);
}
Ok(OlderPage::Events(
page.into_iter().map(|(_, event)| event).collect(),
))
}
/// Before, so that an event held back for a reader who is scrolled
/// away is already on disk -- what the cache holds is what the server
/// sent, not what a screen has got round to drawing. Flushed on each
/// status change, which is a turn's boundary and the granularity a
/// crash may as well lose, and once more when the stream ends.
pub fn follow(
&self,
after: u64,
mut on_item: impl FnMut(StreamItem) -> bool,
) -> Result<(), ApiError> {
let cache = &self.cache;
let result = event_stream::follow_session_events(
self.api.transport(),
&self.session_id,
after,
|item| {
if let StreamItem::Event { raw, event } = &item {
cache.append(raw, event.seq);
if matches!(event.event, event_model::Event::Status { .. }) {
cache.flush();
}
}
on_item(item)
},
);
cache.flush();
result
}
/// Leaves the cache with everything it was given -- called once a
/// caller is done with this source, mirroring the Kotlin `close`'s
/// final flush (that method's stream cancellation itself is the
/// runtime concern the module doc says is not ported here).
pub fn close(&self) {
self.cache.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::api::{Body, RawResponse};
use std::collections::VecDeque;
use std::io::Read;
use std::sync::Mutex;
#[derive(Default)]
struct ScriptedTransport {
responses: Mutex<VecDeque<(u16, String)>>,
calls: Mutex<Vec<String>>,
}
impl ScriptedTransport {
fn respond(&self, status: u16, body: impl Into<String>) {
self.responses
.lock()
.unwrap()
.push_back((status, body.into()));
}
fn call_count(&self) -> usize {
self.calls.lock().unwrap().len()
}
}
impl Transport for ScriptedTransport {
fn request(
&self,
_method: &str,
path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
self.calls.lock().unwrap().push(path.to_string());
let (status, body) = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| panic!("ScriptedTransport got an unscripted request: {path}"));
Ok(RawResponse {
status,
body: body.into_bytes(),
})
}
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
self.calls.lock().unwrap().push(path.to_string());
let (_, body) = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| {
panic!("ScriptedTransport got an unscripted stream request: {path}")
});
Ok(Box::new(std::io::Cursor::new(body.into_bytes())))
}
}
fn source(
transport: ScriptedTransport,
cache_root: &std::path::Path,
) -> TranscriptSource<ScriptedTransport> {
let api = ApiClient::new(transport);
let cache = crate::client::transcript_cache::TranscriptCache::new(cache_root).session("s1");
TranscriptSource::new(api, "s1", cache)
}
fn status_line(seq: u64) -> String {
format!(r#"{{"seq":{seq},"ts":1.0,"type":"status","state":"idle"}}"#)
}
#[test]
fn a_cold_cache_has_no_opening_and_fetches_from_the_server() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
assert_eq!(source.cached_opening(80), None);
let opening = source.fetch_opening().unwrap();
assert_eq!(opening.len(), 1);
assert_eq!(opening[0].seq, 1);
assert!(source.cache.tail().is_some());
}
#[test]
fn probe_matching_the_cached_tail_leaves_the_cache_alone() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(1)));
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().unwrap());
assert!(source2.cache.tail().is_some());
}
#[test]
fn probe_mismatching_the_cached_tail_purges_the_cache() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
transport2.respond(200, format!("[{different}]"));
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(!source2.probe().unwrap());
assert!(source2.cache.tail().is_none());
}
#[test]
fn probe_finding_no_server_leaves_the_cache_untouched() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(500, "server on fire");
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().is_err());
assert!(
source2.cache.tail().is_some(),
"an unreachable server must not be treated as a mismatch"
);
}
#[test]
fn paging_before_the_first_event_makes_no_request_at_all() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
let source = source(transport, dir.path());
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
assert_eq!(source.api.transport().call_count(), 0);
}
#[test]
fn a_page_already_covered_by_the_cache_never_reaches_the_server() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{},{}]", status_line(1), status_line(2)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let calls_before = source.api.transport().call_count();
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
panic!("a cursor of 2 is a real question about the conversation");
};
assert_eq!(page.len(), 1);
assert_eq!(page[0].seq, 1);
assert_eq!(
source.api.transport().call_count(),
calls_before,
"a cache hit must not touch the network"
);
}
#[test]
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(5)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(3)));
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
source2.page(5, 10, true).unwrap();
assert_eq!(
source2.api.transport().calls.lock().unwrap()[0],
"/sessions/s1/transcript?limit=10&before=5&coalesce=true"
);
}
#[test]
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let lines: Vec<String> = (3..6).map(status_line).collect();
assert!(cache.store_page(&lines, 3, 6, true));
cache.append(&status_line(6), 6);
cache.append(&status_line(7), 7);
cache.flush();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(9)));
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
source.page(10, 10, true).unwrap();
assert_eq!(
source.api.transport().calls.lock().unwrap()[0],
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
"the fetch must stop one seq below where this phone's copy ends"
);
}
#[test]
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(500, "server on fire");
let source = source(transport, dir.path());
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
}
#[test]
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.store_page(
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
3,
4,
true,
);
cache.append(&status_line(4), 4);
cache.flush();
let transport = ScriptedTransport::default();
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
assert_eq!(
source.api.transport().call_count(),
0,
"a cache hit that cannot be read must not fall through to the server unnoticed"
);
}
#[test]
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.append("not json at all", 1);
cache.flush();
let transport = ScriptedTransport::default();
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
assert_eq!(source.cached_opening(80), None);
assert!(
source.cache.tail().is_none(),
"a damaged line purges the cache"
);
}
#[test]
fn follow_writes_events_to_the_cache_before_the_caller_sees_them() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("{}\n\n", sse_frame(&status_line(1))));
let source = source(transport, dir.path());
let mut seen = Vec::new();
source
.follow(0, |item| {
if let StreamItem::Event { event, .. } = item {
seen.push(event.seq);
}
true
})
.unwrap();
assert_eq!(seen, vec![1]);
assert_eq!(source.cache.tail().unwrap().seq, 1);
}
fn sse_frame(data: &str) -> String {
format!("data:{data}")
}
}
+337
View File
@@ -0,0 +1,337 @@
use crate::client::api::{ApiClient, SessionSummary, UreqTransport};
use crate::client::event_stream::{StreamItem, follow_session_events};
use crate::client::transcript_fold::{
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
};
use event_model::SeqEvent;
use iris::prelude::*;
use std::{
process,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
thread,
};
const LIST_WIDTH: f32 = 260.0;
enum AppEvent {
Sessions(Result<Vec<SessionSummary>, String>),
TranscriptLoaded {
session_id: String,
generation: u64,
result: Result<Vec<TranscriptItem>, String>,
},
StreamEvent {
session_id: String,
generation: u64,
event: SeqEvent,
},
StreamEnded {
session_id: String,
generation: u64,
message: Option<String>,
},
SendFailed(String),
}
pub fn run() {
DesktopApp::<Client>::run();
}
#[derive(DesktopUiState)]
struct Client {
ui_state: DesktopUiState,
api: Arc<ApiClient<UreqTransport>>,
stream_transport: Arc<UreqTransport>,
proxy: Proxy<AppEvent>,
sessions: Vec<SessionSummary>,
selected: Option<String>,
items: Vec<TranscriptItem>,
list_ptr: WeakWidget<WidgetPtr>,
transcript_ptr: WeakWidget<WidgetPtr>,
screen: Option<crate::ui::TranscriptScreen>,
generation: Arc<AtomicU64>,
}
impl DesktopAppState for Client {
type Event = AppEvent;
fn new(
mut ui_state: DesktopUiState,
rsc: &mut DesktopRsc<Self>,
proxy: Proxy<AppEvent>,
) -> Self {
let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| {
eprintln!("desktop-app: {e}");
process::exit(2);
});
let build_transport =
|| UreqTransport::new(server.base_url(), server.token.clone(), &ca_pem);
let (rest_transport, stream_transport) = build_transport()
.and_then(|rest| build_transport().map(|stream| (rest, stream)))
.unwrap_or_else(|e| {
eprintln!(
"desktop-app: couldn't set up TLS to {}: {e}",
server.base_url()
);
process::exit(1);
});
let api = Arc::new(ApiClient::new(rest_transport));
let stream_transport = Arc::new(stream_transport);
let list_ptr = WidgetPtr::new().add(rsc);
let transcript_ptr = WidgetPtr::new().add(rsc);
let loading = placeholder(rsc, "Loading sessions...");
transcript_ptr(rsc).set(loading);
(list_ptr.width(LIST_WIDTH), transcript_ptr.width(rest(1)))
.span(Dir::RIGHT)
.set_root(rsc, &mut ui_state);
let client = Self {
ui_state,
api,
stream_transport,
proxy,
sessions: Vec::new(),
selected: None,
items: Vec::new(),
list_ptr,
transcript_ptr,
screen: None,
generation: Arc::new(AtomicU64::new(0)),
};
client.spawn_fetch_sessions();
client
}
fn event(&mut self, event: AppEvent, rsc: &mut DesktopRsc<Self>) {
match event {
AppEvent::Sessions(Ok(sessions)) => {
self.sessions = sessions;
self.rebuild_list(rsc);
if self.selected.is_none() {
self.show_message(rsc, "Select a session.");
}
}
AppEvent::Sessions(Err(message)) => {
self.show_message(rsc, &format!("Couldn't list sessions: {message}"));
}
AppEvent::TranscriptLoaded {
session_id,
generation,
result,
} => {
if self.current(&session_id, generation) {
match result {
Ok(items) => {
self.items = items;
self.rebuild_transcript(rsc);
}
Err(message) => {
self.show_message(
rsc,
&format!("Couldn't load {session_id}: {message}"),
);
}
}
}
}
AppEvent::StreamEvent {
session_id,
generation,
event,
} => {
if self.current(&session_id, generation) {
let old_items = self.items.clone();
self.items = fold_event(&self.items, &event);
match self.screen.as_mut() {
Some(screen) => screen.apply(rsc, &old_items, &self.items),
None => self.rebuild_transcript(rsc),
}
}
}
AppEvent::StreamEnded {
session_id,
generation,
message: Some(message),
} => {
if self.current(&session_id, generation) {
eprintln!("desktop-app: {session_id}'s live connection ended: {message}");
}
}
AppEvent::StreamEnded { .. } => {}
AppEvent::SendFailed(message) => {
eprintln!("desktop-app: couldn't send: {message}");
}
}
self.ui_state.window.request_redraw();
}
}
impl Client {
fn current(&self, session_id: &str, generation: u64) -> bool {
self.selected.as_deref() == Some(session_id)
&& self.generation.load(Ordering::SeqCst) == generation
}
fn show_message(&mut self, rsc: &mut DesktopRsc<Self>, message: &str) {
let widget = placeholder(rsc, message);
(self.transcript_ptr)(rsc).set(widget);
}
fn spawn_fetch_sessions(&self) {
let api = self.api.clone();
let proxy = self.proxy.clone();
thread::spawn(move || {
let result = api.fetch_sessions().map_err(|e| e.to_string());
let _ = proxy.send_event(AppEvent::Sessions(result));
});
}
fn rebuild_list(&mut self, rsc: &mut DesktopRsc<Self>) {
let list = Span::empty(Dir::DOWN).gap(2).add(rsc);
for session in &self.sessions {
let selected = self.selected.as_deref() == Some(session.id.as_str());
let row = session_row(rsc, session, selected);
list(rsc).push(row);
}
let tree = list
.background(rect(Srgba8::rgb(24, 24, 28)))
.add_strong(rsc)
.any();
(self.list_ptr)(rsc).set(tree);
}
fn select_session(&mut self, rsc: &mut DesktopRsc<Self>, session_id: String) {
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
self.selected = Some(session_id.clone());
self.items.clear();
self.screen = None;
self.rebuild_list(rsc);
self.show_message(rsc, "Loading transcript...");
let api = self.api.clone();
let stream_transport = self.stream_transport.clone();
let proxy = self.proxy.clone();
let live_generation = self.generation.clone();
thread::spawn(move || {
let page: Result<Vec<serde_json::Value>, String> = api
.fetch_transcript_page(&session_id, None, 200, true)
.map_err(|e| e.to_string());
let after = page
.as_ref()
.ok()
.and_then(|values| raw_seq(values.last()?))
.unwrap_or(0);
let result = page.and_then(|values| fold_page(&values));
let _ = proxy.send_event(AppEvent::TranscriptLoaded {
session_id: session_id.clone(),
generation,
result,
});
let stop = || live_generation.load(Ordering::SeqCst) != generation;
if stop() {
return;
}
let outcome =
follow_session_events(&*stream_transport, &session_id, after, |item| match item {
StreamItem::Open | StreamItem::Reset => !stop(),
StreamItem::Event { event, .. } => {
if stop() {
return false;
}
let _ = proxy.send_event(AppEvent::StreamEvent {
session_id: session_id.clone(),
generation,
event,
});
true
}
});
let _ = proxy.send_event(AppEvent::StreamEnded {
session_id,
generation,
message: outcome.err().map(|e| e.to_string()),
});
});
}
fn send_message(&mut self, session_id: String, text: String) {
let api = self.api.clone();
let proxy = self.proxy.clone();
thread::spawn(move || {
if let Err(e) = api.send_message(&session_id, &text, &[]) {
let _ = proxy.send_event(AppEvent::SendFailed(e.to_string()));
}
});
}
fn rebuild_transcript(&mut self, rsc: &mut DesktopRsc<Self>) {
let in_progress = self
.screen
.as_ref()
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
.filter(|t| !t.is_empty());
let rows = group_tool_runs(&self.items);
let (screen, tree) = crate::ui::build_tree(rsc, rows);
if let Some(text) = in_progress {
screen.composer.field.edit(rsc).set(&text);
}
if let Some(session_id) = self.selected.clone() {
let field = screen.composer.field;
rsc.register_event(field, Submit, move |ctx, rsc| {
let text = field.edit(rsc).take();
let text = text.trim().to_string();
if !text.is_empty() {
ctx.state.send_message(session_id.clone(), text);
}
});
}
(self.transcript_ptr)(rsc).set(tree);
self.screen = Some(screen);
}
}
fn session_row(
rsc: &mut DesktopRsc<Client>,
session: &SessionSummary,
selected: bool,
) -> StrongWidget {
let bg = if selected {
Srgba8::rgb(58, 90, 138)
} else {
Srgba8::rgb(38, 38, 44)
};
let id = session.id.clone();
let label = format!("{}\n{}", session.title, session.status);
wtext(label)
.color(PaintId::WHITE)
.wrap(true)
.pad(10)
.width(rest(1))
.background(rect(bg))
.on(
CursorSense::click(),
move |ctx, rsc: &mut DesktopRsc<Client>| {
ctx.state.select_session(rsc, id.clone());
},
)
.add_strong(rsc)
.any()
}
fn placeholder(rsc: &mut DesktopRsc<Client>, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
.any()
}
+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;
+67
View File
@@ -0,0 +1,67 @@
//! 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 std::{env, fs, path::PathBuf};
use super::config;
struct Args {
ca_path: Option<PathBuf>,
link: Option<String>,
}
fn parse_args() -> Result<Args, String> {
let mut ca_path = None;
let mut link = None;
let mut args = env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--ca" => ca_path = Some(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), _) => 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())
}
+95
View File
@@ -0,0 +1,95 @@
mod jcall;
mod notify;
mod settings;
mod share;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JClass, JObject};
use jni::sys::jint;
use jni::{Env, NativeMethod, native_method};
fn ensure_logger() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
#[cfg(target_os = "android")]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("android-shell"),
);
});
}
// The parameters are spelled as their Java types, not as `JObject`: the
// macro encodes each argument into the exported symbol's JNI signature
// (and JNI resolves `Java_...` names *by* that signature), so a generic
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
// method actually declared `(Landroid/app/Activity;...)` -- two different
// symbols that never resolve to each other, silently, with no compiler
// error on either side. `android.app.Activity` etc. have no dedicated
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
// implementation functions below (the "Built-in Types" note in
// `native_method!`'s docs).
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.MainActivity",
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
error_policy = LogErrorAndDefault,
};
fn native_handle_intent<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
activity: JObject<'local>,
intent: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &activity)?;
share::handle_intent(env, &activity, &intent)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_sync(context: android.content.Context) -> (),
error_policy = LogErrorAndDefault,
};
fn native_sync<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
context: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &context)?;
notify::sync(env, &context)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_start_command(service: android.app.Service) -> jint,
error_policy = LogErrorAndDefault,
};
fn native_on_start_command<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
service: JObject<'local>,
) -> Result<jint, jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &service)?;
Ok(notify::on_start_command(env, service))
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_destroy() -> (),
error_policy = LogErrorAndDefault,
};
fn native_on_destroy<'local>(
_env: &mut Env<'local>,
_class: JClass<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
notify::on_destroy();
Ok(())
}
+534
View File
@@ -0,0 +1,534 @@
//! Where a notification is said, and the foreground service that keeps
//! the connection open while the app is closed. Ported from
//! `Notifications.kt`'s `NotificationService`, minus the "session on
//! screen" / "hand to the app as a banner" branches: those read
//! process-wide state that only exists because a screen is drawn to
//! register against, and this experiment draws no screen yet (that is
//! E4's job, on iris). So every notification here takes the third branch
//! Kotlin's `show` already had -- the platform's own drawer -- which is
//! also exactly the case E3's pass condition asks for: **a notification
//! arrives with the app closed.**
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::client::api::UreqTransport;
use crate::client::notifications::{SessionNotification, follow_notifications};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JValue};
use jni::sys::{JNI_TRUE, jint};
use crate::shell::settings::{self, ServerSettings};
const ALERT_CHANNEL: &str = "sessions";
const ONGOING_CHANNEL: &str = "connection";
const ONGOING_ID: i32 = 1;
const ALERT_ID: i32 = 2;
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
/// Whether the follow-loop thread is already running. **A deviation from
/// `Notifications.kt`, found by testing rather than planned**: the Kotlin
/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }`
/// on *every* call, with nothing to notice a previous one is still going --
/// and `sync()` calling `startForegroundService` when the service is
/// already running is an ordinary Android start, not a restart, so
/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls
/// `sync` once itself, then again inside `handle_enrollment` after saving
/// the token) hits exactly this path and was observed opening **two**
/// concurrent connections to `/notifications` from one process -- caught
/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached
/// thread ai-app-notifications` lines for one enrollment. Guarded here
/// rather than left to match Kotlin's behaviour exactly, since duplicating
/// a live connection is a resource leak with no upside; worth carrying the
/// same guard back to `Notifications.kt` separately.
static RUNNING: AtomicBool = AtomicBool::new(false);
static STOPPING: AtomicBool = AtomicBool::new(false);
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
crate::shell::jcall::get_static_field(env, class, field, "I")?.i()
}
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
crate::shell::jcall::call_static_method(
env,
"androidx/core/app/NotificationManagerCompat",
"from",
"(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;",
&[JValue::Object(context)],
)?
.l()
}
fn create_channel(
env: &mut Env,
manager: &JObject,
id: &str,
name: &str,
importance: i32,
) -> Result<()> {
let id_j = crate::shell::jcall::jstr_obj(env, id)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationChannelCompat$Builder",
"(Ljava/lang/String;I)V",
&[JValue::Object(&id_j), JValue::Int(importance)],
)?;
let name_j = crate::shell::jcall::jstr_obj(env, name)?;
crate::shell::jcall::call_method(
env,
&builder,
"setName",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
&[JValue::Object(&name_j)],
)?;
let channel = crate::shell::jcall::call_method(
env,
&builder,
"build",
"()Landroidx/core/app/NotificationChannelCompat;",
&[],
)?
.l()?;
crate::shell::jcall::call_method(
env,
manager,
"createNotificationChannel",
"(Landroidx/core/app/NotificationChannelCompat;)V",
&[JValue::Object(&channel)],
)?;
Ok(())
}
/// Two channels, because they are two different things to be told -- see
/// `Notifications.kt`'s `createChannels` for the reasoning; the names and
/// importances here are copied from it exactly, since a phone that has
/// seen both apps should not learn two different vocabularies for the
/// same fact.
fn create_channels(env: &mut Env, context: &JObject) -> Result<()> {
let manager = notification_manager(env, context)?;
let default = static_int(
env,
"androidx/core/app/NotificationManagerCompat",
"IMPORTANCE_DEFAULT",
)?;
let min = static_int(
env,
"androidx/core/app/NotificationManagerCompat",
"IMPORTANCE_MIN",
)?;
create_channel(
env,
&manager,
ALERT_CHANNEL,
"Sessions needing attention",
default,
)?;
create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?;
Ok(())
}
fn new_intent_for<'l>(
env: &mut Env<'l>,
context: &JObject,
class_name: &str,
) -> Result<JObject<'l>> {
let target_class = crate::shell::jcall::find_class(env, class_name)?;
crate::shell::jcall::new_object(
env,
"android/content/Intent",
"(Landroid/content/Context;Ljava/lang/Class;)V",
&[JValue::Object(context), JValue::Object(&target_class)],
)
}
/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s
/// `sessionIntent`, including building the URI through `Uri.Builder`
/// rather than string concatenation, for the same reason: an id needing
/// escaping must survive the round trip.
fn session_intent<'l>(
env: &mut Env<'l>,
context: &JObject,
session_id: &str,
) -> Result<JObject<'l>> {
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
let action_view = crate::shell::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
crate::shell::jcall::call_method(
env,
&intent,
"setAction",
"(Ljava/lang/String;)Landroid/content/Intent;",
&[JValue::Object(&action_view)],
)?;
let builder = crate::shell::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
let scheme = crate::shell::jcall::jstr_obj(env, settings::SCHEME)?;
crate::shell::jcall::call_method(
env,
&builder,
"scheme",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&scheme)],
)?;
let authority = crate::shell::jcall::jstr_obj(env, "session")?;
crate::shell::jcall::call_method(
env,
&builder,
"authority",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&authority)],
)?;
let path = crate::shell::jcall::jstr_obj(env, session_id)?;
crate::shell::jcall::call_method(
env,
&builder,
"appendPath",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&path)],
)?;
let uri = crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?
.l()?;
crate::shell::jcall::call_method(
env,
&intent,
"setData",
"(Landroid/net/Uri;)Landroid/content/Intent;",
&[JValue::Object(&uri)],
)?;
Ok(intent)
}
fn pending_activity<'l>(
env: &mut Env<'l>,
context: &JObject,
intent: &JObject,
) -> Result<JObject<'l>> {
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
crate::shell::jcall::call_static_method(
env,
"android/app/PendingIntent",
"getActivity",
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
&[
JValue::Object(context),
JValue::Int(0),
JValue::Object(intent),
JValue::Int(update_current | immutable),
],
)?
.l()
}
fn builder_call<'l>(
env: &mut Env<'l>,
builder: &JObject<'l>,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<()> {
crate::shell::jcall::call_method(env, builder, method, sig, args)?;
Ok(())
}
/// The type Android 14+ requires a foreground service to declare, and
/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`.
fn foreground_type(env: &mut Env) -> Result<i32> {
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?;
if sdk >= upside_down_cake {
static_int(
env,
"android/content/pm/ServiceInfo",
"FOREGROUND_SERVICE_TYPE_SPECIAL_USE",
)
} else {
Ok(0)
}
}
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
let channel = crate::shell::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::shell::jcall::jstr_obj(env, "Watching for sessions that need you")?;
builder_call(
env,
&builder,
"setContentTitle",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?;
builder_call(
env,
&builder,
"setSmallIcon",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(icon)],
)?;
builder_call(
env,
&builder,
"setOngoing",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?;
builder_call(
env,
&builder,
"setPriority",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(priority_min)],
)?;
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
/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`.
pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
let service_intent =
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
if settings::load(env, context)?.is_none() {
crate::shell::jcall::call_method(
env,
context,
"stopService",
"(Landroid/content/Intent;)Z",
&[JValue::Object(&service_intent)],
)?;
return Ok(());
}
create_channels(env, context)?;
crate::shell::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"startForegroundService",
"(Landroid/content/Context;Landroid/content/Intent;)V",
&[JValue::Object(context), JValue::Object(&service_intent)],
)?;
Ok(())
}
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
match try_start(env, &service) {
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
Ok(false) => {
let _ = crate::shell::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
}
Err(e) => {
log_error(env, "onStartCommand", &e);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
}
}
}
fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
let Some(settings) = settings::load(env, service)? else {
return Ok(false);
};
let ca = settings::load_pinned_ca(env)?;
let notification = ongoing_notification(env, service)?;
let fg_type = foreground_type(env)?;
crate::shell::jcall::call_static_method(
env,
"androidx/core/app/ServiceCompat",
"startForeground",
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
&[
JValue::Object(service),
JValue::Int(ONGOING_ID),
JValue::Object(&notification),
JValue::Int(fg_type),
],
)?;
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
// the first is still going -- the ordinary case for this service,
// since `sync()` is called from more than one place -- must not open
// a second connection.
if RUNNING.swap(true, Ordering::SeqCst) {
return Ok(true);
}
let vm = env.get_java_vm()?;
let context = env.new_global_ref(service)?;
STOPPING.store(false, Ordering::SeqCst);
std::thread::Builder::new()
.name("ai-app-notifications".to_string())
.spawn(move || {
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
follow_loop(env, &context, settings, &ca);
Ok(())
});
})
.ok();
Ok(true)
}
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
while !STOPPING.load(Ordering::SeqCst) {
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
let _ = follow_notifications(&transport, |notification| {
if let Err(e) = show(env, context, &notification) {
log_error(env, "show", &e);
}
!STOPPING.load(Ordering::SeqCst)
});
}
if STOPPING.load(Ordering::SeqCst) {
return;
}
std::thread::sleep(RECONNECT_DELAY);
}
}
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
let manager = notification_manager(env, context)?;
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
let allowed = if sdk < tiramisu {
true
} else {
let permission =
crate::shell::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
let granted = static_int(
env,
"android/content/pm/PackageManager",
"PERMISSION_GRANTED",
)?;
let result = crate::shell::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"checkSelfPermission",
"(Landroid/content/Context;Ljava/lang/String;)I",
&[JValue::Object(context), JValue::Object(&permission)],
)?
.i()?;
result == granted
};
let enabled =
crate::shell::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?
.z()?;
if !allowed || !enabled {
return Ok(());
}
let intent = session_intent(env, context, &notification.session_id)?;
let pending = pending_activity(env, context, &intent)?;
let channel = crate::shell::jcall::jstr_obj(env, ALERT_CHANNEL)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::shell::jcall::jstr_obj(env, &notification.title)?;
builder_call(
env,
&builder,
"setContentTitle",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let text = crate::shell::jcall::jstr_obj(env, notification.kind.attention_line())?;
builder_call(
env,
&builder,
"setContentText",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&text)],
)?;
let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?;
builder_call(
env,
&builder,
"setSmallIcon",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(icon)],
)?;
builder_call(
env,
&builder,
"setContentIntent",
"(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&pending)],
)?;
builder_call(
env,
&builder,
"setAutoCancel",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let when = (notification.at * 1000.0) as i64;
builder_call(
env,
&builder,
"setWhen",
"(J)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Long(when)],
)?;
builder_call(
env,
&builder,
"setShowWhen",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let built = crate::shell::jcall::call_method(
env,
&builder,
"build",
"()Landroid/app/Notification;",
&[],
)?
.l()?;
let tag = crate::shell::jcall::jstr_obj(env, &notification.session_id)?;
crate::shell::jcall::call_method(
env,
&manager,
"notify",
"(Ljava/lang/String;ILandroid/app/Notification;)V",
&[
JValue::Object(&tag),
JValue::Int(ALERT_ID),
JValue::Object(&built),
],
)?;
Ok(())
}
pub fn on_destroy() {
STOPPING.store(true, Ordering::SeqCst);
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
// comment): the old thread may still be inside a blocked read when a
// new `onStartCommand` follows immediately, which would spawn a
// second one before the first has actually stopped. Narrower than not
// resetting at all -- a service destroyed and never restarted would
// otherwise wedge `RUNNING` true forever -- and no worse than the
// known gap already accepted above.
RUNNING.store(false, Ordering::SeqCst);
}
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
let message = format!("android-shell: {where_}: {error}");
let _ = (|| -> Result<()> {
let tag = crate::shell::jcall::jstr_obj(env, "android-shell")?;
let msg = crate::shell::jcall::jstr_obj(env, &message)?;
crate::shell::jcall::call_static_method(
env,
"android/util/Log",
"e",
"(Ljava/lang/String;Ljava/lang/String;)I",
&[JValue::Object(&tag), JValue::Object(&msg)],
)?;
Ok(())
})();
}
+133
View File
@@ -0,0 +1,133 @@
//! Enrollment: where the backend is, and the Keystore-sealed token to
//! reach it. This crate does not reimplement the Android Keystore AES-GCM
//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin
//! class the production app already uses (see `ServerConfig.kt`), through
//! JNI, for two reasons: that code is shared with Dev Updater and already
//! tested, and the sealed value on a real phone is keyed to the exact
//! Keystore alias that class already uses -- reimplementing the crypto
//! here would either duplicate it or invalidate an existing enrollment.
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
/// Where the backend is and how to authenticate to it -- the Rust twin of
/// `wg-app-link`'s `ServerSettings` data class, read back field by field
/// rather than kept as a live JNI reference, so it can cross a thread
/// boundary (a `JObject` is tied to one `Env`/thread).
#[derive(Debug, Clone)]
pub struct ServerSettings {
pub host: String,
pub port: i32,
pub token: String,
}
impl ServerSettings {
pub fn base_url(&self) -> String {
format!("https://{}:{}", self.host, self.port)
}
}
pub(crate) const SCHEME: &str = "aiappshell";
const KEY_ALIAS: &str = "aiapp-shell-token-key";
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
let scheme = crate::shell::jcall::jstr_obj(env, SCHEME)?;
let alias = crate::shell::jcall::jstr_obj(env, KEY_ALIAS)?;
crate::shell::jcall::new_object(
env,
STORE_CLASS,
"(Ljava/lang/String;Ljava/lang/String;)V",
&[JValue::Object(&scheme), JValue::Object(&alias)],
)
}
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
let host = get_string(env, settings_obj, "getHost")?;
let port = crate::shell::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
let token = get_string(env, settings_obj, "getToken")?;
Ok(ServerSettings { host, port, token })
}
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
let value =
crate::shell::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
let jstr: JString = env.cast_local::<JString>(value)?;
jstr.try_to_string(env)
}
/// The stored enrollment, or `None` when there is not one -- mirrors
/// `ServerConfig.kt`'s `loadServerSettings`.
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::shell::jcall::call_method(
env,
&store,
"load",
"(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;",
&[JValue::Object(context)],
)?
.l()?;
if settings_obj.is_null() {
return Ok(None);
}
Ok(Some(read_settings(env, &settings_obj)?))
}
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
let store = new_store(env)?;
let host = crate::shell::jcall::jstr_obj(env, &settings.host)?;
let token = crate::shell::jcall::jstr_obj(env, &settings.token)?;
let settings_obj = crate::shell::jcall::new_object(
env,
SETTINGS_CLASS,
"(Ljava/lang/String;ILjava/lang/String;)V",
&[
JValue::Object(&host),
JValue::Int(settings.port),
JValue::Object(&token),
],
)?;
crate::shell::jcall::call_method(
env,
&store,
"save",
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
&[JValue::Object(context), JValue::Object(&settings_obj)],
)?;
Ok(())
}
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::shell::jcall::call_method(
env,
&store,
"parseEnrollmentUri",
"(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;",
&[JValue::Object(uri)],
)?
.l()?;
if settings_obj.is_null() {
return Ok(None);
}
Ok(Some(read_settings(env, &settings_obj)?))
}
/// The CA this build pins, generated at build time the same way
/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`)
/// but into a plain Java constant, since this module has no Kotlin of its
/// own to generate into.
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
let value = crate::shell::jcall::get_static_field(
env,
"com/example/aiapp/shell/PinnedCa",
"PINNED_CA_PEM",
"Ljava/lang/String;",
)?
.l()?;
let jstr: JString = env.cast_local::<JString>(value)?;
Ok(jstr.try_to_string(env)?.into_bytes())
}
+157
View File
@@ -0,0 +1,157 @@
use crate::client::api::{ApiClient, UreqTransport};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
use crate::shell::notify;
use crate::shell::settings;
const ACTION_SEND: &str = "android.intent.action.SEND";
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
const ACTION_VIEW: &str = "android.intent.action.VIEW";
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
let value =
crate::shell::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
if value.is_null() {
return Ok(None);
}
let jstr: JString = env.cast_local::<JString>(value)?;
Ok(Some(jstr.try_to_string(env)?))
}
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
let message = crate::shell::jcall::jstr_obj(env, message)?;
crate::shell::jcall::call_static_method(
env,
"com/example/aiapp/shell/MainActivity",
"toast",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&message)],
)?;
Ok(())
}
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let action = get_string_method(env, intent, "getAction")?;
if matches!(
action.as_deref(),
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
) {
return handle_share(env, activity, intent);
}
if action.as_deref() != Some(ACTION_VIEW) {
return Ok(());
}
let uri = crate::shell::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?
.l()?;
if uri.is_null() {
return Ok(());
}
let scheme = get_string_method(env, &uri, "getScheme")?;
if scheme.as_deref() != Some(settings::SCHEME) {
return Ok(());
}
match get_string_method(env, &uri, "getHost")?.as_deref() {
Some("session") => handle_session_open(env, activity, &uri),
Some("enroll") => handle_enrollment(env, activity, &uri),
_ => Ok(()),
}
}
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
return Ok(());
};
toast(env, activity, &format!("Opened session {session_id}"))
}
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
match settings::parse_enrollment_uri(env, uri)? {
Some(parsed) => {
settings::save(env, activity, &parsed)?;
notify::sync(env, activity)?;
toast(
env,
activity,
&format!("Enrolled with {}", parsed.base_url()),
)
}
None => toast(env, activity, "Not a valid enrollment code"),
}
}
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let extra_text = crate::shell::jcall::jstr_obj(env, EXTRA_TEXT)?;
let text = crate::shell::jcall::call_method(
env,
intent,
"getStringExtra",
"(Ljava/lang/String;)Ljava/lang/String;",
&[JValue::Object(&extra_text)],
)?
.l()?;
let text = if text.is_null() {
None
} else {
let jstr: JString = env.cast_local::<JString>(text)?;
Some(jstr.try_to_string(env)?)
};
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
return toast(
env,
activity,
"Nothing to share -- only shared text is supported so far",
);
};
// Network I/O must not run on the calling thread: `handle_intent` is
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
// blocking socket read there is a `NetworkOnMainThreadException`. So
// the actual send happens on a JNI-attached background thread, the
// same shape `notify::try_start`'s follow loop uses; `toast` from that
// thread is safe because `MainActivity.toast` itself hops back to the
// main looper (see that method).
let vm = env.get_java_vm()?;
let activity_ref = env.new_global_ref(activity)?;
std::thread::spawn(move || {
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
share_in_background(env, &activity_ref, text);
Ok(())
});
});
Ok(())
}
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
let outcome = attach_to_a_session(env, activity, &text);
let message = match outcome {
Ok(title) => format!("Shared into \"{title}\""),
Err(message) => message,
};
let _ = toast(env, activity, &message);
}
fn attach_to_a_session(
env: &mut Env,
activity: &JObject,
text: &str,
) -> std::result::Result<String, String> {
let settings = settings::load(env, activity)
.map_err(|e| e.to_string())?
.ok_or_else(|| "Not enrolled yet".to_string())?;
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
.map_err(|e| e.to_string())?;
let client = ApiClient::new(transport);
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
let target = sessions
.into_iter()
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
.ok_or_else(|| "No session to share into".to_string())?;
client
.send_message(&target.id, text, &[])
.map_err(|e| e.to_string())?;
Ok(target.title)
}
+74
View File
@@ -0,0 +1,74 @@
use crate::ui::theme::Theme;
use iris::prelude::*;
const MAX_LINES: f32 = 6.0;
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
const FIELD_PAD_DP: f32 = 12.0;
/// `field` is exposed so the caller can read its content on submit
/// (`field.edit(rsc).text()`) and clear it afterward
/// (`field.edit(rsc).set("")`).
pub struct Composer {
pub field: WeakWidget<TextEdit>,
/// The bar's own outer padding -- only `bottom` is ever changed, by
/// [`Self::set_bottom_inset`]. A `Pad` around the whole bar rather than
/// a rebuilt tree, because `field` lives inside it and cannot be
/// re-added to a new wrapper once it is strongly owned here.
outer_pad: WeakWidget<Pad>,
}
impl Composer {
/// Called by the platform shell (Android's `on_insets_changed`, e.g.)
/// whenever the space below the bar changes: the IME's own inset while
/// it is open, the navigation-bar inset otherwise. Takes a plain
/// `f32` in the caller's own physical-pixel units rather than an
/// Android-specific insets type, so this crate stays usable from the
/// winit backend too, which has no navigation bar to report.
/// Rewrites the existing `Pad` in place (marking it dirty through the
/// ordinary `Widgets::get_mut` path) instead of swapping in a new one,
/// so the field's focus, selection and in-progress text are untouched.
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
pad.padding.bottom = Len::abs(inset);
pad.exact_region = true;
}
}
}
/// Returns the composer plus its own bar as a **weak** id -- the caller
/// (`lib.rs::build`) embeds it in the screen's own top-level tuple, whose
/// `set_root` performs the one real strong registration. Calling
/// `.add_strong`/`.upgrade` a second time on an id already strong-owned
/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same
/// mistake this box's `row.rs` first made with its sender-label header, see
/// that file's comment for the fuller account.
pub fn build_composer<Rsc: HasEvents>(rsc: &mut Rsc, theme: &Theme) -> (Composer, WeakWidget)
where
Rsc::State: FocusHost,
{
let field = wtext("")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(true)
.size(18)
.color(theme.text.clone())
.attr::<Selectable>(())
.label("Message")
.add(rsc);
// Without any mask at all the overflow paints *above* the bar, over
// the transcript: measured at 58px of stray text for a 475px message
// in a 417px box.
let content = field
.width(rest(1))
.scrollable(Axis::Y, Pin::End)
.pad(dp(FIELD_PAD_DP))
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
.width(rest(1))
.masked_by(rect(theme.composer_surface.clone()))
.add(rsc);
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
(Composer { field, outer_pad }, outer_pad)
}
+129
View File
@@ -0,0 +1,129 @@
use crate::client::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
use event_model::SeqEvent;
use iris::prelude::*;
pub const BACKLOG_COUNT: usize = 3202;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
pub const PHONE_WIDTH: f32 = 1080.0;
pub const PHONE_HEIGHT: f32 = 2424.0;
pub const PHONE_SCALE: f32 = 2.55;
pub const PHONE_FRAME_MS: u64 = 8;
pub fn phone_size() -> Vec2 {
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
}
pub struct Fixture {
pub backlog: Vec<serde_json::Value>,
pub stream_tail: Vec<SeqEvent>,
}
impl Fixture {
/// Parses the whole fixture. Panics on malformed input: this is a
/// generated file compiled into the binary, so a parse failure is a
/// broken build rather than a condition a caller could recover from
/// (CODE_RULES: separate recoverable conditions from programmer
/// error).
pub fn parse() -> Self {
let mut backlog = Vec::with_capacity(BACKLOG_COUNT);
let mut stream_tail = Vec::new();
for (i, line) in FIXTURE_JSONL
.lines()
.filter(|line| !line.trim().is_empty())
.enumerate()
{
let value: serde_json::Value =
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
if i < BACKLOG_COUNT {
backlog.push(value);
} else {
stream_tail.push(
serde_json::from_value(value)
.expect("bench fixture event matches event-model's SeqEvent"),
);
}
}
Self {
backlog,
stream_tail,
}
}
/// The opening page folded into transcript items -- the same
/// `fold_page` a real first load runs. `Err` carries the fold's own
/// message, which a caller shows on screen rather than panicking, so
/// a fixture that stops folding is visible in the app instead of
/// being a crash on launch.
pub fn backlog_items(&self) -> Result<Vec<TranscriptItem>, String> {
fold_page(&self.backlog)
}
}
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
group_tool_runs(items)
}
/// Everything a caller needs to run the fixture as an app screen would:
/// the screen, the folded items behind it, and the events not yet
/// streamed. The tree itself comes back separately from
/// [`build_screen`], since whoever takes it owns it.
pub struct Opened {
pub screen: crate::ui::TranscriptScreen,
pub items: Vec<TranscriptItem>,
/// The tail, for a caller that goes on replaying it one event at a
/// time through `fold_event`/`TranscriptScreen::apply` -- the
/// streaming phase of either app's benchmark.
pub stream_tail: Vec<SeqEvent>,
}
/// Build the transcript screen over the fixture's opening page, without
/// claiming the window's root -- `crate::ui::build_tree`'s own split,
/// for a caller (the Android bench) that puts the screen inside a shell
/// of its own.
pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String>
where
Rsc::State: FocusHost + OpenUrl,
{
let fixture = Fixture::parse();
let items = fixture.backlog_items()?;
let (screen, tree) = crate::ui::build_tree(rsc, rows(&items));
Ok((
Opened {
screen,
items,
stream_tail: fixture.stream_tail,
},
tree,
))
}
pub fn open<Rsc: HasEvents>(
rsc: &mut Rsc,
ui_state: &mut impl HasRoot<Rsc>,
) -> Result<Opened, String>
where
Rsc::State: FocusHost + OpenUrl,
{
let (opened, tree) = build_screen(rsc)?;
ui_state.set_root(rsc, tree);
Ok(opened)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_fixture_has_a_backlog_and_a_streaming_tail() {
let fixture = Fixture::parse();
assert_eq!(fixture.backlog.len(), BACKLOG_COUNT);
assert!(
fixture.stream_tail.len() >= 400,
"the stream phase replays 400 events; the fixture has {}",
fixture.stream_tail.len()
);
assert!(!fixture.backlog_items().expect("the page folds").is_empty());
}
}
+644
View File
@@ -0,0 +1,644 @@
use crate::client::highlight::{self, Kind, Language};
use crate::client::markdown_blocks::{Block, BlockKind};
use crate::ui::theme::Theme;
use iris::prelude::*;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::ops::Range;
fn syntax_color(kind: Kind, theme: &Theme) -> PaintId {
match kind {
Kind::Keyword => theme.syntax_keyword.clone(),
Kind::String => theme.syntax_string.clone(),
Kind::Literal => theme.syntax_literal.clone(),
Kind::Comment => theme.syntax_comment.clone(),
Kind::Metadata => theme.syntax_metadata.clone(),
Kind::Punctuation => theme.syntax_punctuation.clone(),
Kind::Mark => theme.syntax_mark.clone(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockFrame {
Plain,
Verbatim { fill: PaintId },
Quote,
}
pub fn frame_of(kind: BlockKind, theme: &Theme) -> BlockFrame {
match kind {
BlockKind::Code => BlockFrame::Verbatim {
fill: theme.verbatim_surface.clone(),
},
BlockKind::Table => BlockFrame::Verbatim {
fill: theme.table_surface.clone(),
},
BlockKind::Quote => BlockFrame::Quote,
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
BlockFrame::Plain
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Link {
pub range: Range<usize>,
pub url: String,
}
#[derive(Clone, Default)]
pub struct Rendered {
pub text: String,
pub spans: Vec<SpanStyle>,
pub links: Vec<Link>,
}
impl Rendered {
pub fn link_at(&self, byte: usize) -> Option<&Link> {
self.links.iter().find(|l| l.range.contains(&byte))
}
}
/// The heading ladder, in points at a 16pt body: it starts near the body
/// text and descends, because these are headings inside a chat message
/// rather than the top of a document. The numbers are Material's
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
/// -- kept as literals rather than derived from `base_size` so the two
/// apps agree exactly.
fn heading_size(level: HeadingLevel) -> f32 {
match level {
HeadingLevel::H1 => 24.0,
HeadingLevel::H2 => 22.0,
HeadingLevel::H3 => 16.0,
HeadingLevel::H4 => 14.0,
HeadingLevel::H5 => 12.0,
HeadingLevel::H6 => 11.0,
}
}
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
/// A block-level separator inside one block's own text (a list item's
/// paragraphs, a quote's): two never run into each other with no gap, but
/// an empty `out` gets no leading blank.
fn ensure_blank_line(out: &mut String) {
if !out.is_empty() && !out.ends_with("\n\n") {
while out.ends_with('\n') {
out.pop();
}
out.push_str("\n\n");
}
}
fn ensure_line(out: &mut String) {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
}
pub fn render_block(block: &Block, base_size: f32, theme: &Theme) -> Rendered {
match block.kind {
BlockKind::Table => table_text(&block.source, theme),
_ => render_markdown(&block.source, base_size, theme),
}
}
/// One markdown source string rendered into plain text plus the spans that
/// style it. `base_size` is the row's ordinary paragraph font size, needed
/// only so a heading's override is relative to it rather than a hardcoded
/// absolute the caller cannot retune.
pub fn render_markdown(src: &str, base_size: f32, theme: &Theme) -> Rendered {
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
let mut out = String::new();
let mut spans = Vec::new();
let mut links = Vec::new();
// Stack of start byte offsets for whatever inline/block styling is
// currently open -- pulldown-cmark's `Start`/`End` events are always
// balanced and each `End` already names its own kind (`TagEnd`), so a
// plain offset stack (rather than a tree, or repeating the kind here
// too) is enough. A link's destination rides along beside its offset,
// since `TagEnd::Link` does not carry it.
let mut open: Vec<(usize, Option<String>)> = Vec::new();
// One entry per open list: `Some(next number)` for an ordered list,
// `None` for a bulleted one. Depth is this vector's length, which is
// what picks the bullet glyph.
let mut lists: Vec<Option<u64>> = Vec::new();
let mut fence_language: Option<Language> = None;
let parser = Parser::new_ext(src, options());
for event in parser {
match event {
Event::Start(tag) => match tag {
Tag::Heading { .. }
| Tag::Emphasis
| Tag::Strong
| Tag::Strikethrough
| Tag::Image { .. } => open.push((out.len(), None)),
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
Tag::CodeBlock(kind) => {
fence_language = match &kind {
CodeBlockKind::Fenced(info) => {
highlight::fence_language(info.split_whitespace().next())
}
CodeBlockKind::Indented => None,
};
ensure_blank_line(&mut out);
open.push((out.len(), None));
}
Tag::Item => {
ensure_line(&mut out);
let depth = lists.len().max(1);
out.push_str(&" ".repeat(depth - 1));
let start = out.len();
match lists.last_mut() {
Some(Some(n)) => {
out.push_str(&format!("{n}. "));
*n += 1;
}
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
}
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
}
Tag::List(first) => lists.push(first),
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
_ => {}
},
Event::End(
tag_end @ (TagEnd::Heading(_)
| TagEnd::Emphasis
| TagEnd::Strong
| TagEnd::Strikethrough
| TagEnd::Link
| TagEnd::Image
| TagEnd::CodeBlock),
) => {
let Some((start, dest)) = open.pop() else {
continue;
};
if matches!(tag_end, TagEnd::CodeBlock) {
while out.ends_with('\n') {
out.pop();
}
}
let range = start..out.len();
if range.is_empty() {
continue;
}
match tag_end {
TagEnd::Heading(level) => {
spans.push(SpanStyle::new(range).font_size(heading_size(level)).bold());
}
TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()),
TagEnd::Strong => spans.push(SpanStyle::new(range).bold()),
TagEnd::Strikethrough => {
spans.push(SpanStyle::new(range).color(theme.strikethrough.clone()));
}
// An image draws as its alt text until the port has a
// transcript image widget (IRIS_TODO's "scaled
// thumbnail"); marked as a link so it is at least
// followable rather than silently inert.
TagEnd::Link | TagEnd::Image => {
spans.push(
SpanStyle::new(range.clone())
.color(theme.link.clone())
.underline(),
);
if let Some(url) = dest {
links.push(Link { range, url });
}
}
TagEnd::CodeBlock => {
spans.push(
SpanStyle::new(range.clone())
.family(Family::Monospace)
.color(theme.code.clone()),
);
if let Some(language) = fence_language.take() {
highlight_into(&mut spans, &out, range, language, theme);
}
}
_ => unreachable!("filtered by the outer match arm"),
}
}
Event::Text(text) => out.push_str(&text),
Event::Code(text) => {
let start = out.len();
out.push_str(&text);
spans.push(
SpanStyle::new(start..out.len())
.family(Family::Monospace)
.color(theme.code.clone()),
);
}
Event::SoftBreak => out.push(' '),
Event::HardBreak => out.push('\n'),
Event::Rule => {
ensure_line(&mut out);
out.push_str("\u{2500}\u{2500}\u{2500}\n");
}
Event::TaskListMarker(done) => {
let start = out.len();
out.push_str(if done { "[x] " } else { "[ ] " });
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
}
Event::End(TagEnd::List(_)) => {
lists.pop();
}
_ => {}
}
}
while out.ends_with('\n') {
out.pop();
}
spans.retain(|s| s.range.end <= out.len());
links.retain(|l| l.range.end <= out.len());
Rendered {
text: out,
spans,
links,
}
}
fn options() -> Options {
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
}
pub(crate) fn highlight_into(
spans: &mut Vec<SpanStyle>,
text: &str,
range: Range<usize>,
language: Language,
theme: &Theme,
) {
let code = &text[range.clone()];
// char index -> byte offset within `code`, plus the end, so a span's
// `end` is always in range.
let bytes: Vec<usize> = code
.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(code.len()))
.collect();
for span in highlight::spans_of(code, language) {
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
debug_assert!(
false,
"highlight span {}..{} outside {} chars of code",
span.start,
span.end,
bytes.len() - 1
);
continue;
};
spans.push(
SpanStyle::new(range.start + start..range.start + end)
.family(Family::Monospace)
.color(syntax_color(span.kind, theme)),
);
}
}
const TABLE_MAX_COL: usize = 28;
pub fn table_text(src: &str, theme: &Theme) -> Rendered {
let rows = table_cells(src);
if rows.is_empty() {
return Rendered::default();
}
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
let wrapped: Vec<Vec<Vec<String>>> = rows
.iter()
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
.collect();
let widths: Vec<usize> = (0..columns)
.map(|c| {
wrapped
.iter()
.filter_map(|row| row.get(c))
.flat_map(|lines| lines.iter())
.map(|l| l.chars().count())
.max()
.unwrap_or(0)
})
.collect();
let mut out = String::new();
let mut spans = Vec::new();
for (r, row) in wrapped.iter().enumerate() {
let height = row.iter().map(Vec::len).max().unwrap_or(1);
let start = out.len();
for line in 0..height {
if !out.is_empty() {
out.push('\n');
}
for (c, width) in widths.iter().enumerate() {
if c > 0 {
out.push_str(" ");
}
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
let text = text.unwrap_or("");
out.push_str(text);
if c + 1 < widths.len() {
for _ in text.chars().count()..*width {
out.push(' ');
}
}
}
}
if r == 0 {
spans.push(SpanStyle::new(start..out.len()).bold());
out.push('\n');
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
let rule_start = out.len();
out.extend(std::iter::repeat_n('\u{2500}', rule));
spans.push(SpanStyle::new(rule_start..out.len()).color(theme.quote_bar.clone()));
}
}
Rendered {
text: out,
spans,
links: Vec::new(),
}
}
fn table_cells(src: &str) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut cell = String::new();
let mut in_cell = false;
for event in Parser::new_ext(src, options()) {
match event {
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
Event::Start(Tag::TableCell) => {
cell.clear();
in_cell = true;
}
Event::End(TagEnd::TableCell) => {
in_cell = false;
if let Some(row) = rows.last_mut() {
row.push(cell.trim().to_string());
}
}
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
_ => {}
}
}
rows.retain(|r| !r.is_empty());
rows
}
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let extra = if line.is_empty() { 0 } else { 1 };
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
lines.push(line);
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::markdown_blocks::split_blocks;
fn with_theme<T>(f: impl FnOnce(&Theme) -> T) -> T {
let mut paints = Paints::new();
let theme = Theme::new(&mut paints);
f(&theme)
}
fn render_markdown(src: &str, base_size: f32) -> Rendered {
with_theme(|theme| super::render_markdown(src, base_size, theme))
}
fn render_block(block: &Block, base_size: f32) -> Rendered {
with_theme(|theme| super::render_block(block, base_size, theme))
}
fn frame_of(kind: BlockKind) -> BlockFrame {
with_theme(|theme| super::frame_of(kind, theme))
}
fn syntax_color(kind: Kind) -> PaintId {
with_theme(|theme| super::syntax_color(kind, theme))
}
fn code_color() -> PaintId {
with_theme(|theme| theme.code.clone())
}
fn marker_color() -> PaintId {
with_theme(|theme| theme.marker.clone())
}
fn block(src: &str) -> Rendered {
let blocks = split_blocks(src);
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
render_block(&blocks[0], 16.0)
}
#[test]
fn plain_paragraph_has_no_spans() {
let r = render_markdown("just some words", 16.0);
assert_eq!(r.text, "just some words");
assert!(r.spans.is_empty());
}
#[test]
fn bold_and_italic_produce_spans_over_the_right_range() {
let r = render_markdown("a **bold** and *italic* word", 16.0);
assert_eq!(r.text, "a bold and italic word");
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
assert_eq!(&r.text[bold.range.clone()], "bold");
let italic = r.spans.iter().find(|s| s.italic).unwrap();
assert_eq!(&r.text[italic.range.clone()], "italic");
}
#[test]
fn heading_gets_a_bigger_font_size_span() {
let r = render_markdown("# A Title", 16.0);
assert!(r.text.starts_with("A Title"));
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
assert_eq!(&r.text[heading.range.clone()], "A Title");
assert_eq!(heading.font_size, Some(24.0));
}
#[test]
fn every_heading_level_is_a_different_size() {
let mut sizes = Vec::new();
for level in 1..=6 {
let src = format!("{} h", "#".repeat(level));
let r = render_markdown(&src, 16.0);
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
}
let mut sorted = sizes.clone();
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
sorted.dedup();
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
}
#[test]
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
assert!(r.text.contains("the docs"));
assert!(
!r.text.contains("example.com"),
"the URL should not leak into the visible text"
);
let link = r.spans.iter().find(|s| s.underline).unwrap();
assert_eq!(&r.text[link.range.clone()], "the docs");
let at = r.text.find("docs").unwrap();
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
let past = r.text.find("for").unwrap();
assert!(r.link_at(past).is_none());
}
#[test]
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
let r = block("```rust\nlet x = 1; // note\n```");
assert_eq!(r.text, "let x = 1; // note");
let keyword = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
.expect("a rust fence colours its keywords");
assert_eq!(&r.text[keyword.range.clone()], "let");
let comment = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
.unwrap();
assert_eq!(&r.text[comment.range.clone()], "// note");
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
}
#[test]
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
let r = block("```brainfuck\nlet x = 1;\n```");
assert_eq!(r.text, "let x = 1;");
assert_eq!(r.spans.len(), 1);
assert!(r.spans[0].family == Some(Family::Monospace));
assert_eq!(r.spans[0].color, Some(code_color()));
}
#[test]
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
for span in &r.spans {
assert!(
r.text.is_char_boundary(span.range.start)
&& r.text.is_char_boundary(span.range.end),
"span {:?} is not on a char boundary of {:?}",
span.range,
r.text
);
}
let string = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::String)))
.unwrap();
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
}
#[test]
fn an_unterminated_fence_still_renders_what_arrived() {
let r = block("```rust\nlet x = 1;");
assert_eq!(r.text, "let x = 1;");
assert!(
r.spans
.iter()
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
);
}
#[test]
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
let r = block("- one\n- two\n - deep");
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(marker_color()))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
}
#[test]
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
let r = block("3. three\n4. four");
assert_eq!(r.text, "3. three\n4. four");
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(marker_color()))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["3. ", "4. "]);
}
#[test]
fn a_quote_is_its_text_and_takes_the_quote_frame() {
let blocks = split_blocks("> quoted words\n> still quoted");
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
let r = render_block(&blocks[0], 16.0);
assert_eq!(r.text, "quoted words still quoted");
}
#[test]
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
use BlockKind::*;
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
assert_eq!(frame_of(Heading), BlockFrame::Plain);
assert_eq!(frame_of(List), BlockFrame::Plain);
assert_eq!(frame_of(Other), BlockFrame::Plain);
assert_eq!(frame_of(Quote), BlockFrame::Quote);
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
assert_ne!(
frame_of(Code),
frame_of(Table),
"a fence and a table sit on different fills"
);
}
#[test]
fn a_table_pads_its_columns_to_the_widest_cell() {
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
let lines: Vec<&str> = r.text.lines().collect();
assert_eq!(lines[0], "a bb");
assert_eq!(lines[1], "\u{2500}".repeat(8));
assert_eq!(lines[2], "cccc d");
let bold = r.spans.iter().find(|s| s.bold).unwrap();
assert_eq!(&r.text[bold.range.clone()], "a bb");
}
#[test]
fn a_long_table_cell_wraps_inside_its_column() {
let long = "one two three four five six seven eight nine ten eleven twelve";
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
for line in r.text.lines() {
assert!(
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
"line too wide: {line:?}"
);
}
assert!(r.text.contains("twelve"));
}
#[test]
fn a_task_list_marks_its_boxes() {
let r = block("- [x] done\n- [ ] not");
assert!(r.text.contains("[x] done"));
assert!(r.text.contains("[ ] not"));
}
}
+786
View File
@@ -0,0 +1,786 @@
pub mod composer;
// Keep the 1.9 MB fixture out of ordinary APKs.
#[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::{TranscriptItem, TranscriptRow as FoldedRow, group_tool_runs};
use iris::prelude::*;
use std::{mem, 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: usize,
tail: Option<(RowKey, row::TailRow)>,
session_working: 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>(&mut 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 row::BuiltRow { key, widget, tail } = row::build_row(
rsc,
self.list,
row,
self.session_working,
true,
self.theme.clone(),
);
(self.list)(rsc).push_back(LazyItem::new(key, widget));
self.tail = tail.map(|t| (key, t));
}
pub fn set_session_working<Rsc: HasEvents>(&mut self, rsc: &mut Rsc, working: bool)
where
Rsc::State: FocusHost + OpenUrl,
{
if self.session_working == working {
return;
}
self.session_working = working;
if let Some((_, row::TailRow::Tools(tools))) = self.tail.as_mut() {
let calls = tools.calls();
tools.apply_calls(rsc, &calls, working);
}
}
#[cfg(test)]
fn tail_card_count(&self) -> usize {
match self.tail.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>(&mut self, rsc: &mut Rsc, expanded: bool) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
let Some((_, row::TailRow::Tools(tools))) = self.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>(
&mut self,
rsc: &mut Rsc,
key: RowKey,
row: &FoldedRow,
) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
let Some((tail_key, kept)) = self.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, 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)
}
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working)
}
(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>(
&mut self,
rsc: &mut Rsc,
old: &[TranscriptItem],
new: &[TranscriptItem],
) where
Rsc::State: FocusHost + OpenUrl,
{
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 row::BuiltRow {
key: new_key,
widget,
tail: kept,
} = row::build_row(
rsc,
self.list,
&new_rows[common],
self.session_working,
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 = kept.map(|t| (new_key, t));
for row in &new_rows[common + 1..] {
self.push_row(rsc, row);
}
}
RowDiff::Rebuild => {
self.rebuilds += 1;
(self.list)(rsc).clear();
self.tail = None;
for row in &new_rows {
self.push_row(rsc, row);
}
}
}
}
pub fn take_rebuilds(&mut self) -> usize {
mem::take(&mut self.rebuilds)
}
/// 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 row::BuiltRow {
key,
widget,
tail: 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,
session_working: false,
list,
composer,
rebuilds: 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::*;
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::text_cap::MESSAGE_LINES;
use std::iter;
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: Ui,
events: EventManager<TestRsc>,
}
impl UiRsc for TestRsc {
fn ui(&self) -> &Ui {
&self.ui
}
fn ui_mut(&mut self) -> &mut Ui {
&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: Ui::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 (mut screen, tree) = build_tree(&mut rsc, 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 RenderCounters { 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 (mut screen, tree) = build_tree(rsc, 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: Ui::default(),
events: EventManager::default(),
};
let items = run_of(3, output, true);
let (mut screen, tree) = build_tree(&mut rsc, 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);
render.take_counters().shapes
}
#[test]
fn collapsed_cards_shape_only_their_summary_lines() {
let long: String = 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: Ui::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, group_tool_runs(&items));
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
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(MESSAGE_LINES * 4));
let bigger = shapes_for_message(&paragraphs(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: Ui::default(),
events: EventManager::default(),
};
let before = run_of(count, "", false);
let mut after = before.clone();
after[0] = call("t0", "the result", true);
let (mut 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"
);
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: Ui::default(),
events: EventManager::default(),
};
let before = run_of(3, "", false);
let mut after = before.clone();
after[1] = call("t1", "done", true);
let (mut screen, tree) = build_tree(&mut rsc, 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: Ui::default(),
events: EventManager::default(),
};
let before = run_of(2, "ok", true);
let mut after = before.clone();
after.push(call("t2", "", false));
let (mut 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: Ui::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 (mut screen, _tree) = build_tree(&mut rsc, 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);
}
}
+567
View File
@@ -0,0 +1,567 @@
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::{
ItemKey, 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, build_tool_row};
use iris::prelude::*;
use std::{
cell::RefCell,
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
rc::Rc,
slice,
};
const BLOCK_GAP_DP: f32 = 8.0;
pub const BASE_SIZE: f32 = 16.0;
/// Maps string run IDs above the sequence-number range used by transcripts.
pub fn row_key(key: &ItemKey) -> RowKey {
match key {
ItemKey::Seq(seq) => *seq,
ItemKey::RunId(id) => {
let mut h = 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<LinkTargets>,
column: WeakWidget<Span>,
sender: Option<String>,
/// A capped row must be rebuilt before accepting a delta.
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
}
}
/// Caps at a block boundary when possible, or within the first oversized block.
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)
}
/// Shared with the "Show all" callback to avoid copying a long message.
struct RowSource {
sender: Option<String>,
markdown: String,
}
#[derive(Clone)]
struct LinkTargets(Rc<RefCell<Vec<Link>>>);
impl LinkTargets {
fn new(links: Vec<Link>) -> Self {
Self(Rc::new(RefCell::new(links)))
}
fn replace(&self, links: Vec<Link>) {
*self.0.borrow_mut() = links;
}
fn url_at(&self, byte: usize) -> Option<String> {
self.0
.borrow()
.iter()
.find(|link| link.range.contains(&byte))
.map(|link| link.url.clone())
}
}
struct BuiltBlock {
field: WeakWidget<Text>,
widget: StrongWidget,
links: LinkTargets,
}
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) -> BuiltBlock
where
Rsc::State: FocusHost + OpenUrl,
{
let frame = frame_of(block.kind, theme);
let rendered = render_block(block, BASE_SIZE, theme);
let links = LinkTargets::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);
// Panning or selecting across a link must not open it.
if outcome == SelectionInput::Tapped {
let byte = field.selection(rsc).byte_at(pos, size);
let url = tap_links.url_at(byte);
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(),
};
BuiltBlock {
field,
widget: 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 built = build_block(rsc, block, &theme);
fields.push(built.field);
links.push(built.links);
column.push(built.widget);
}
if let Some(lines) = hidden {
column.push(show_all(
rsc,
list,
key,
source.clone(),
ptr,
lines,
theme.clone(),
));
}
let column = column.add(rsc);
// The parent composition performs the header's single strong registration.
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,
},
)
}
/// Rebuilds a capped row uncapped; its incremental state is intentionally discarded.
#[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 {
/// Updates only the changed tail blocks, or returns `false` when a rebuild is required.
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;
}
if self.capped {
return false;
}
let new_blocks = display_blocks(markdown_src);
let common = common_prefix(&self.blocks, &new_blocks);
// A delta may append or rewrite only the current final block.
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.replace(rendered.links);
}
_ => {
let built = build_block(rsc, block, &self.theme);
self.fields.push(built.field);
self.links.push(built.links);
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
column.push(built.widget);
}
}
}
}
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)
}
/// Incremental state retained for whichever kind of row is currently last.
pub enum TailRow {
Blocks(RowBlocks),
Tools(ToolRow),
}
pub struct BuiltRow {
pub key: RowKey,
pub widget: StrongWidget,
pub tail: Option<TailRow>,
}
/// `cap` limits historical rows; a live tail must remain uncapped.
pub fn build_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<LazySpan>,
row: &FoldedRow,
working: bool,
cap: bool,
theme: Rc<Theme>,
) -> BuiltRow
where
Rsc::State: FocusHost + OpenUrl,
{
// A single tool call still draws as a card, without a redundant group wrapper.
let calls = match row {
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => Some(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) = build_tool_row(rsc, list, key, calls.to_vec(), working, theme);
return BuiltRow {
key,
widget,
tail: 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);
BuiltRow {
key,
widget,
tail: 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,
)
}
+633
View File
@@ -0,0 +1,633 @@
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, RefCell},
collections::{HashMap, HashSet},
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: 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,
);
}
+193
View File
@@ -0,0 +1,193 @@
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)> {
let render_handle = h.rsc.ui.render_state();
let render_state = render_handle.get();
render_state
.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 = render_state.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 mut 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 mut 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, mut 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);
}
}
+270
View File
@@ -0,0 +1,270 @@
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.rsc
.ui
.render_state()
.get()
.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
.rsc
.ui
.render_state()
.get()
.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 render_handle = h.rsc.ui.render_state();
let render_state = render_handle.get();
let message = render_state
.debug(h.rsc.widgets(), "Message")
.find(|a| !a.primitives.is_empty())
.expect("the composer field is drawn");
let caret = render_state.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 = render_state.primitive_corners(mask.primitive, &h.rsc);
let visible_content_top = message
.primitives
.iter()
.map(|p| render_state.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,
);
}
+256
View File
@@ -0,0 +1,256 @@
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.rsc
.ui
.render_state()
.get()
.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 render_handle = h.rsc.ui.render_state();
let render_state = render_handle.get();
let mut rows: Vec<(f32, f32)> = render_state
.active
.get(&screen.list.id())
.expect("the list is drawn")
.children
.iter()
.filter_map(|id| render_state.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 render_handle = h.rsc.ui.render_state();
let render_state = render_handle.get();
let active = render_state.active.get(&screen.list.id()).expect("drawn");
assert!(
active.mask != MaskIdx::NONE,
"the transcript's list is drawn with nothing clipping it",
);
let clip = render_state.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 = render_state
.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 render_handle = h.rsc.ui.render_state();
let render_state = render_handle.get();
let Some(active) = render_state.active.get(&id) else {
return Vec::new();
};
let mut out: Vec<MaskIdx> = active
.primitives
.iter()
.filter(|p| p.binding != IMAGE_BINDING)
.map(|p| render_state.primitives.instance(p.slot).mask_idx)
.collect();
for child in &active.children {
out.extend(primitives_under(h, *child));
}
out
}
fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
let mut chain = Vec::new();
let mut at = mask;
while at != MaskIdx::NONE {
assert!(
!chain.contains(&at),
"the mask chain from {mask:?} loops back to {at:?}",
);
chain.push(at);
at = h.rsc.ui.masks[at.idx()].parent;
}
chain
}
#[test]
fn rows_that_have_left_the_viewport_are_not_drawn() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
let bounded = |rows: &[(f32, f32)], leg: &str, step: usize| {
assert!(
rows.len() <= 24,
"{leg} {step}: {} rows drawn for one 2012px viewport",
rows.len(),
);
};
let inside = |rows: &[(f32, f32)], leg: &str, step: usize| {
for &(top, bottom) in rows {
assert!(
bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5,
"{leg} {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \
{list:?} and was drawn anyway",
);
}
};
for step in 0..40 {
t = scrolled(&mut h, &screen, 400.0, t);
bounded(&drawn_rows(&h, &screen), "measuring", step);
}
for step in 0..40 {
t = scrolled(&mut h, &screen, -400.0, t);
let rows = drawn_rows(&h, &screen);
bounded(&rows, "forward", step);
inside(&rows, "forward", step);
}
for step in 0..40 {
t = scrolled(&mut h, &screen, 400.0, t);
let rows = drawn_rows(&h, &screen);
bounded(&rows, "back", step);
inside(&rows, "back", step);
}
}
#[test]
fn the_row_across_the_bottom_edge_is_drawn() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..40 {
t = scrolled(&mut h, &screen, 37.0, t);
let rows = drawn_rows(&h, &screen);
let last = *rows.last().expect("something is on screen");
assert!(
last.1 >= list.bot_right.y - 0.5,
"a band of {:.1}px above the composer belongs to no row",
list.bot_right.y - last.1,
);
assert!(
last.0 < list.bot_right.y,
"the row across the bottom edge was culled: it starts at {:.1}, below the list's own \
{:.1}",
last.0,
list.bot_right.y,
);
}
}
#[test]
fn scrolling_past_the_first_row_settles_on_it() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..60 {
t = scrolled(&mut h, &screen, 100_000.0, t);
}
let rows = drawn_rows(&h, &screen);
let first = *rows.first().expect("the first row is on screen");
assert!(
(first.0 - list.top_left.y).abs() < 0.5,
"the transcript is parked {:.1}px past its own first row, so the top of the list is blank",
first.0 - list.top_left.y,
);
}
#[test]
fn scrolling_past_the_last_row_settles_on_it() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..20 {
t = scrolled(&mut h, &screen, -100_000.0, t);
}
let rows = drawn_rows(&h, &screen);
let last = *rows.last().expect("the last row is on screen");
assert!(
(last.1 - list.bot_right.y).abs() < 0.5,
"the transcript is parked {:.1}px past its own last row, so the bottom of the list is \
blank",
list.bot_right.y - last.1,
);
}
+21
View File
@@ -0,0 +1,21 @@
# A finger flick the shape Iris's phone delivers one, from
# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's
# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app
# as DOWN, one or two MOVEs and UP inside a few frames, with the
# intermediate positions batched inside those MOVEs as historical
# samples (~4ms apart, the touch digitiser's own rate) rather than
# arriving as separate events. Each line here is one such sample, which
# is exactly what `IrisViewPeer::on_touch_event` replays through the
# sensors one at a time -- so the whole gesture is 20ms and five
# samples, and the velocity has to come out of *those*.
#
# Downward (increasing y) on purpose: the screen opens pinned to the
# newest end, so a flick the other way has nothing left to scroll to and
# the fling clamps on its first tick -- a pass that would prove nothing.
# Coordinates are physical pixels on a 1080x2424 surface.
0 down 540 1000
4 move 540 1040
8 move 540 1086
12 move 540 1138
16 move 540 1196
20 up 540 1196
+11
View File
@@ -0,0 +1,11 @@
# `flick-120hz.touch` to the sample, with the platform taking the gesture
# away instead of the finger lifting -- Android's `ACTION_CANCEL`, which
# is what the swipe up from the bottom edge to leave the app delivers
# after its moves. Nothing may follow from it: no tap, no selection and,
# the one that showed on Iris's phone, no fling.
0 down 540 1000
4 move 540 1040
8 move 540 1086
12 move 540 1138
16 move 540 1196
20 cancel 540 1196
+11
View File
@@ -0,0 +1,11 @@
# A long-press then a drag across the text: held past LONG_PRESS
# (500ms) without moving, which is what starts a selection rather than a
# pan, then dragged sideways so the selection actually covers
# something. A press alone leaves a collapsed caret and no selected
# text (`Selection::begin`), which is why this file does not stop at the
# hold.
0 down 300 1000
520 move 300 1000
560 move 700 1000
600 move 900 1000
640 up 900 1000
+5
View File
@@ -0,0 +1,5 @@
# The case the flick had no reason to touch: a press and release in one
# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a
# tap -- no pan, no velocity, nothing moved.
0 down 540 1000
80 up 540 1000
+39
View File
@@ -102,6 +102,16 @@ android {
targetSdk = 37 targetSdk = 37
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
// Read by MainActivity to decide, at startup, whether this is the P0 benchmark build
// (docs/RUST.md's P0 box) rather than the app somebody enrolled. False everywhere except
// the `bench` build type below, which overrides it.
buildConfigField("boolean", "FIXTURE_MODE", "false")
}
buildFeatures {
// Only for FIXTURE_MODE above; nothing else here reaches for generated BuildConfig fields.
buildConfig = true
// Only for the bench build type's resValue("string", "app_name", ...) below.
resValues = true
} }
packaging { packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
@@ -131,6 +141,35 @@ android {
isMinifyEnabled = false isMinifyEnabled = false
if (keystore != null) signingConfig = signingConfigs.getByName("release") if (keystore != null) signingConfig = signingConfigs.getByName("release")
} }
// P0's benchmark build (docs/RUST.md, docs/DECISIONS.md's 2026-09-05 entry): release
// optimisations so a frame time measured here means what release means everywhere else in
// this project, its own application id so it installs beside a real enrollment rather than
// replacing it, and FIXTURE_MODE so MainActivity opens straight onto the fixture session
// instead of asking to be enrolled. Signed with the same key as release -- it never talks
// to a real backend, so there is no CA of its own to mismatch, and a second keystore would
// be one more secret to keep off this machine's shared mount for no benefit.
create("bench") {
initWith(getByName("release"))
// :link (wg-app-link) has no "bench" build type of its own -- it is a library shared
// with dev-updater and has no reason to know this project invented one -- so this says
// which of its build types to link against instead.
matchingFallbacks += listOf("release")
applicationIdSuffix = ".bench"
// "AI Sessions bench" everywhere the OS shows the app's name (launcher, recents,
// Settings): this resValue overrides res/values/strings.xml's app_name for this
// build type alone, and AndroidManifest.xml's android:label reads @string/app_name
// rather than a literal so a build type can override it without touching the
// manifest.
resValue("string", "app_name", "AI Sessions bench")
buildConfigField("boolean", "FIXTURE_MODE", "true")
if (keystore != null) signingConfig = signingConfigs.getByName("release")
}
}
sourceSets {
// The fixture both bench builds (this one and iris's) open with; see
// app/bench-fixture/README.md. Read directly from its own directory rather than copied
// into androidApp/src -- one file to keep in sync with the generator, not two.
getByName("bench").assets.directories.add("../bench-fixture/assets")
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_21
+1 -1
View File
@@ -25,7 +25,7 @@
the fix is a judgement about how this app should look. Drop this the fix is a judgement about how this app should look. Drop this
suppression when a real icon lands. --> suppression when a real icon lands. -->
<application <application
android:label="AI Sessions" android:label="@string/app_name"
android:allowBackup="true" android:allowBackup="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar" android:theme="@android:style/Theme.Material.Light.NoActionBar"
tools:ignore="MissingApplicationIcon"> tools:ignore="MissingApplicationIcon">
File diff suppressed because it is too large. Load diff
@@ -11,7 +11,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
@@ -22,7 +21,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.wgapplink.localNetworkAllowed import com.example.wgapplink.localNetworkAllowed
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -33,39 +31,41 @@ import kotlinx.coroutines.withContext
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* and the back button the only other way between them. * and the back button the only other way between them.
* *
* Import, models and machines are tabs inside [MainScreen] -- four views of the same backend, none * Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
* of them a step down from another -- and what is left here is only what genuinely is a step down: * them a step down from another -- and what is left here is only what genuinely is a step down: one
* one session, spawning one, and settings. * session, spawning one, and settings.
*/ */
private sealed class Screen { private sealed class Screen {
data object Main : Screen() /**
* The session list, with a subagent's own transcript over it when [subagent] is set.
*
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
* itself the moment its own subagent's view was closed.
*/
data class Main(val subagent: SubagentTarget? = null) : Screen()
/** /**
* One session, with the file explorer or a subagent transcript over it when set. * One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
* * docs/SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* Both are layers on this screen rather than screens of their own, so the session under them * is opened from the session list's card rather than from inside the session it belongs to.
* stays composed: its event stream keeps flowing, its scroll position and draft stay put, and
* coming back costs nothing. As sibling `Screen`s they would dispose and recreate it on every
* return, refetching the transcript over the tunnel.
*/ */
data class Session( data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
val summary: SessionSummary,
val files: FilesTarget? = null, /**
val subagent: SubagentSummary? = null, * One session, with the file explorer over it when [files] is set.
) : Screen() *
* The explorer is a layer on this screen rather than a screen of its own, so the session under
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
* created on every return, refetching the transcript over the tunnel.
*/
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
data object Spawn : Screen() data object Spawn : Screen()
/**
* One provider on one machine: its settings, and what its shared server is holding.
*
* A step down from the machines tab rather than a tab of its own, because it is about one
* machine rather than about the backend. Addressed by ids and names rather than by the
* [Provider] it was tapped from: what it shows is fetched, and a stale copy of a card would be
* a second version of the same truth.
*/
data class ProviderSettings(val machineId: String, val provider: String) : Screen()
data object Settings : Screen() data object Settings : Screen()
} }
@@ -99,7 +99,7 @@ fun AppRoot(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.Main) } var screen by remember { mutableStateOf<Screen>(Screen.Main()) }
// A notification tap this could not follow, and why. Null both before one is asked for and // A notification tap this could not follow, and why. Null both before one is asked for and
// after one succeeds, since success is a screen rather than a message. // after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) } var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
@@ -114,7 +114,7 @@ fun AppRoot(
share = shareRequest share = shareRequest
// A session already open takes it. Otherwise the list is where the choice is made, // A session already open takes it. Otherwise the list is where the choice is made,
// whatever screen was showing: Spawn and Settings have nowhere to put a file. // whatever screen was showing: Spawn and Settings have nowhere to put a file.
if (screen !is Screen.Session) screen = Screen.Main if (screen !is Screen.Session) screen = Screen.Main()
} }
} }
@@ -141,7 +141,7 @@ fun AppRoot(
existing = null, existing = null,
onSaved = { saved -> onSaved = { saved ->
settings = saved settings = saved
screen = Screen.Main screen = Screen.Main()
}, },
onBack = null, onBack = null,
) )
@@ -154,7 +154,7 @@ fun AppRoot(
// shows, so it always refetches. // shows, so it always refetches.
val goToMain = { val goToMain = {
reloadToken++ reloadToken++
screen = Screen.Main screen = Screen.Main()
} }
if (screen !is Screen.Main) { if (screen !is Screen.Main) {
BackHandler(onBack = goToMain) BackHandler(onBack = goToMain)
@@ -196,23 +196,44 @@ fun AppRoot(
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is // deliberately does not: resizing a whole screen on every frame of the keyboard animation is
// the cost that made it lag, so it moves only its composer and transcript. // the cost that made it lag, so it moves only its composer and transcript.
when (val here = screen) { when (val here = screen) {
Screen.Main -> is Screen.Main ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding()) {
MainScreen( MainScreen(
settings = current, settings = current,
reloadToken = reloadToken, reloadToken = reloadToken,
share = share, share = share,
onOpen = { screen = Screen.Session(it) }, onOpen = { screen = Screen.Session(it) },
onOpenSubagent = { summary, subagent ->
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
},
onSpawn = { screen = Screen.Spawn }, onSpawn = { screen = Screen.Spawn },
onImported = { imported -> onImported = { imported ->
reloadToken++ reloadToken++
screen = Screen.Session(imported) screen = Screen.Session(imported)
}, },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
) )
// Its own back handler is registered after MainScreen's, so it is the one the
// platform asks first while a subagent is open -- the same rule the files
// explorer's handler follows over its session, below.
here.subagent?.let { target ->
BackHandler { screen = here.copy(subagent = null) }
// Its own opaque background: this screen was always the sole content under
// the theme's own Surface before, so it never had to paint one -- stacked over
// the list here, the space between its own cards let the list underneath show
// through without this. The same fix FilesScreen needed over its session.
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
key(target.summary.id, target.subagent.id) {
SessionScreen(
settings = current,
summary = target.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = target.subagent,
)
}
}
}
} }
is Screen.Session -> is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this // Keyed on the id, because a different session is a different screen rather than this
@@ -225,77 +246,6 @@ fun AppRoot(
// A Box so the explorer can be drawn *over* the session rather than instead of it. // A Box so the explorer can be drawn *over* the session rather than instead of it.
// No imePadding here, for the reason above -- the explorer adds its own. // No imePadding here, for the reason above -- the explorer adds its own.
Box { Box {
val fileLinkHandler = rememberFileLinkHandler { path ->
screen = here.copy(files = here.summary.filesTarget(path))
}
Box(
Modifier.then(
if (here.subagent != null || here.files != null)
Modifier.clearAndSetSemantics {}
else Modifier
)
) {
// How much background work the session has, from the one subscription
// to its events the screen below holds. Here because the panel and that
// screen both draw it, and must draw the same number.
var backgroundTasks by
remember(here.summary.id) {
mutableIntStateOf(here.summary.backgroundTasks)
}
// Where the panel has asked the session screen to put the reader: the
// call a background task was started by. Held here rather than inside
// either, because the two are siblings -- the panel is the one being
// tapped and the transcript is the one that can travel.
var goTo by remember(here.summary.id) { mutableStateOf<CallSite?>(null) }
// The two panels this session can be pulled aside for: its subagents
// from the right, and the whole main screen from the left. Both are here
// rather than screens of their own for the same reason the explorer is --
// the session under them stays composed. The main panel exists only
// inside a session, which is what makes it unswipeable until one has been
// opened.
SidePanels(
left = { active, close ->
MainPanel(
settings = current,
sessionId = here.summary.id,
active = active,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings },
onProvider = { machineId, provider ->
screen = Screen.ProviderSettings(machineId, provider)
},
onClose = close,
onGone = goToMain,
)
},
// The whole width: it stands in for the screen Back would have shown,
// rather than sitting over the session the way the subagents do.
leftFraction = 1f,
right = { active, close ->
SubagentPanel(
settings = current,
summary = here.summary,
active = active,
backgroundTasks = backgroundTasks,
onOpenSubagent = { screen = here.copy(subagent = it) },
// Closed with it: what the reader asked to see is under this
// panel, and a panel left open over the answer is the one
// thing the tap cannot have meant.
onOpenCall = {
goTo = it
close()
},
)
},
) {
CompositionLocalProvider(
LocalFileLinkHandler provides fileLinkHandler
) {
SessionScreen( SessionScreen(
settings = current, settings = current,
summary = here.summary, summary = here.summary,
@@ -303,29 +253,7 @@ fun AppRoot(
onFiles = { screen = here.copy(files = it) }, onFiles = { screen = here.copy(files = it) },
share = share, share = share,
onShareTaken = { share = null }, onShareTaken = { share = null },
onBackgroundTasks = { backgroundTasks = it },
goTo = goTo,
onGoToTaken = { goTo = null },
) )
}
}
}
here.subagent?.let { subagent ->
BackHandler { screen = here.copy(subagent = null) }
Box(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
) {
key(subagent.id) {
SessionScreen(
settings = current,
summary = here.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = subagent,
)
}
}
}
// Its own back handler is registered after this screen's, so it is the one the // Its own back handler is registered after this screen's, so it is the one the
// platform asks first, and it steps back inside itself before closing. // platform asks first, and it steps back inside itself before closing.
here.files?.let { target -> here.files?.let { target ->
@@ -337,15 +265,6 @@ fun AppRoot(
} }
} }
} }
is Screen.ProviderSettings ->
Box(Modifier.imePadding()) {
ProviderScreen(
settings = current,
machineId = here.machineId,
provider = here.provider,
onBack = goToMain,
)
}
is Screen.Spawn -> is Screen.Spawn ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding()) {
SpawnScreen( SpawnScreen(
@@ -20,6 +20,7 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -334,11 +335,12 @@ private fun OtherAnswer(text: String, onText: (String) -> Unit) {
// No Send of its own: this is one more way to answer the question, and the card's Submit is // No Send of its own: this is one more way to answer the question, and the card's Submit is
// what sends it. A second send button beside the field made the shorter half of the card look // what sends it. A second send button beside the field made the shorter half of the card look
// like the one that finishes it. // like the one that finishes it.
LabelledField( OutlinedTextField(
label = "Other",
value = text, value = text,
onValueChange = onText, onValueChange = onText,
modifier = Modifier.padding(top = 8.dp), label = { Text("Other") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) )
} }
@@ -11,30 +11,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* Whether a session can be sent a picture, as the server answers it.
*
* Three states rather than a switch, because for a local model the answer belongs to the server
* that loaded it: one still coming off disk genuinely has not said. [UNKNOWN] is offered -- a
* control withheld because nobody could ask is a photo button missing from a session that would
* have read the photo perfectly well, and the send path says so if the guess was wrong.
*/
enum class ImageSupport {
ACCEPTED,
REFUSED,
UNKNOWN,
}
/**
* What the server called it; anything else -- an older server, a newer word -- is not an answer.
*/
fun imageSupport(word: String): ImageSupport =
when (word) {
"accepted" -> ImageSupport.ACCEPTED
"refused" -> ImageSupport.REFUSED
else -> ImageSupport.UNKNOWN
}
/** /**
* Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension * Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension
* from the list it writes -- rather than a file kept under its own name. Mirrors the server's * from the list it writes -- rather than a file kept under its own name. Mirrors the server's
@@ -38,11 +38,6 @@ suspend fun uploadPickedImage(
* Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage] * Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage]
* and is shrunk; anything else goes whole, under the name the other app or the file chooser gave * and is shrunk; anything else goes whole, under the name the other app or the file chooser gave
* it, because the session is told that name rather than shown the bytes. * it, because the session is told that name rather than shown the bytes.
*
* A picture is refused here, before anything is read or sent, when [images] says this session's
* model cannot read one. Here rather than beside the photo button because this is where every way
* of attaching meets: the picker, the file chooser, and another app's share sheet -- and only the
* first of those has a button to disable.
*/ */
suspend fun uploadPicked( suspend fun uploadPicked(
context: Context, context: Context,
@@ -50,16 +45,10 @@ suspend fun uploadPicked(
sessionId: String, sessionId: String,
uri: Uri, uri: Uri,
maxEdge: Int?, maxEdge: Int?,
images: ImageSupport,
): String { ): String {
val resolver = context.contentResolver val resolver = context.contentResolver
val mime = resolver.getType(uri) val mime = resolver.getType(uri)
if (mime != null && mime.startsWith("image/")) { if (mime != null && mime.startsWith("image/")) {
if (images == ImageSupport.REFUSED) {
throw ApiException(
"this session's model can't read pictures, so that one wasn't attached"
)
}
return uploadPickedImage(context, settings, sessionId, uri, maxEdge) return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
} }
// Opened before the request starts, so a provider that refuses says so here and not from inside // Opened before the request starts, so a provider that refuses says so here and not from inside
@@ -1,244 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* The background work a session has going, above its subagents in the panel [SidePanels] slides
* over it from the right.
*
* Collapsed to its one-line count by default, the way everything else this app adds to a screen
* arrives: what a reader came to the panel for is the subagents, and a run of cards about work
* nobody asked after would push them off it. Expanding pushes them down instead of covering them,
* so the two are read together.
*
* The count is drawn even when it is zero, in the same words. A section that appeared only once
* something was running made its own presence the answer, and no heading at all draws "nothing is
* running" and "nobody has asked yet" identically. There is then nothing to expand, so the heading
* carries no chevron either: it is a statement rather than a control.
*/
fun LazyListScope.backgroundTaskSection(
count: Int,
tasks: LoadState<List<BackgroundTaskSummary>?>,
expanded: Boolean,
onToggle: () -> Unit,
onRetry: () -> Unit,
onOpenCall: (CallSite) -> Unit,
) {
item(key = "background-heading") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.then(if (count == 0) Modifier else Modifier.clickable(onClick = onToggle)),
) {
Text(
"${backgroundTaskLabel(count)} running",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
if (count > 0) Chevron(if (expanded) Pointing.Up else Pointing.Down)
}
}
// The count as well as the switch: [tasks] is the last answer anybody got, so a section left
// expanded as the work finished would draw cards for tasks that have ended.
if (count == 0 || !expanded) return
when (tasks) {
is LoadState.Loading ->
item(key = "background-loading") {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
is LoadState.Error ->
item(key = "background-error") {
Column {
Text(
tasks.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = onRetry) { Text("Try again") }
}
}
// Null is the provider declining to say, which a session whose process has gone answers.
// Said in words: the count above came from somewhere, and an empty space under it would
// read as the tasks having finished rather than as nobody being left to ask.
is LoadState.Loaded ->
when (val rows = tasks.value) {
null ->
item(key = "background-unknown") {
Text(
"This session isn't saying what these are.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
else ->
uniqueItems(rows, key = { "background-${it.id}" }) { task ->
BackgroundTaskCard(
task,
onOpen = task.call?.let { call -> { onOpenCall(call) } },
)
}
}
}
}
/**
* One background task: what it is doing, drawn as one line that says what kind it is by how it
* looks.
*
* The kind used to be a second line under the words, which on a list of backgrounded commands was
* "background command" repeated down the panel -- and for a provider that names a task by a process
* id it was the *whole* card, so every row said the same two words. A mark carries the same
* difference in a width the text does not have to make room for, and it is the [Glyph]'s
* description that keeps the words for anybody who cannot see it.
*
* A command needs no mark: drawn the way every other verbatim thing here is -- highlighted,
* monospace, on [rawSurface] -- it says "this is a command" in the same appearance the tool card it
* came from uses, and a mark beside that would be the same fact twice.
*
* [onOpen] is where the call that started this is in the transcript, for the readers who tap it:
* null where the provider never said which call it was, or where that call is no longer in the
* transcript, and the card is then a statement rather than a control. The chevron is what says
* which of the two this is, since a card that quietly does nothing when pressed is worse than one
* that never invited the press.
*/
@Composable
private fun BackgroundTaskCard(task: BackgroundTaskSummary, onOpen: (() -> Unit)?) {
val look = backgroundTaskLook(task.kind)
// Null where a provider named the task by a process id and nothing resolved a command out of
// it: there is no code to draw, so the row takes the mark and the words instead.
val command = task.description?.takeIf { look.code }
OutlinedCard(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.then(
if (onOpen == null) Modifier
else
Modifier.clickable(
onClickLabel = "Show where this started",
onClick = onOpen,
)
)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
if (command == null) {
Glyph(
look.glyph,
colour = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.semantics { contentDescription = look.words },
)
Spacer(Modifier.width(10.dp))
// The kind stands in as the words where the provider gave no description, rather
// than the id it named the task by: Codex reports a process number, which says
// nothing to the person reading and would look like a name somebody chose.
Text(
task.description ?: look.words,
style = MaterialTheme.typography.bodyMedium,
color =
if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant
else LocalContentColor.current,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
} else {
// Cut at its tail: what identifies a command is the program at its head, and the
// long ones are exactly the ones being read closely.
Text(
// Not cached: one command line lexes in microseconds -- the cache exists for a
// fence with two hundred lines in it.
remember(command) { highlight(command, Language.SHELL) },
style =
MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier.weight(1f)
// Smaller than the card's own radius, for the reason [RawBlock] rounds
// its corners that way: this sits inside one.
.clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface)
.padding(horizontal = 6.dp, vertical = 4.dp)
// The fill says "command" to everybody else; this says it to a reader
// who cannot see the fill.
.semantics { contentDescription = "${look.words} $command" },
)
}
if (onOpen != null) {
Spacer(Modifier.width(8.dp))
Chevron(Pointing.Right)
}
}
}
}
/**
* How one kind of background task is drawn: see [backgroundTaskLook].
*
* [code] is the kind whose description is verbatim text rather than prose, which is drawn as code
* and takes no [glyph]; the glyph is still what a task of that kind falls back to when nothing said
* what it ran.
*/
private data class TaskLook(val glyph: String, val words: String, val code: Boolean)
/**
* Everything a [BackgroundTaskSummary.kind] decides, answered by one `when`.
*
* One rather than three, which is the rule this screen already learned once with the status word
* and its colour: three `when`s over one set is two of them waiting to miss a member.
*
* A kind this build has not heard of takes the question mark and is named by what every one of them
* has in common. The nearest word or mark we do know -- a robot, a terminal -- would be this screen
* deciding what the server meant by a word it invented after this build shipped.
*/
private fun backgroundTaskLook(kind: String) =
when (kind) {
// A command is drawn in the face a command is drawn in everywhere else here.
"command" -> TaskLook(COMMAND_GLYPH, "background command", code = true)
"agent" -> TaskLook(AGENT_GLYPH, "subagent", code = false)
"workflow" -> TaskLook(WORKFLOW_GLYPH, "workflow", code = false)
else -> TaskLook(UNKNOWN_GLYPH, "background task", code = false)
}
/**
* The heading over one group in the panel, so neither list is a run of cards with no name.
*
* The same band as the background section's own heading row above, rather than a gap chosen to look
* right here: what separates a heading from the cards above it is that both headings sit in a row
* of one height.
*/
@Composable
fun PanelSectionHeading(text: String) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 48.dp)) {
Text(text, style = MaterialTheme.typography.titleMedium)
}
}
@@ -0,0 +1,108 @@
package com.example.aiapp
import android.content.Context
import java.util.concurrent.CopyOnWriteArrayList
/**
* P0's benchmark gate (see docs/RUST.md and the 2026-09-05 decision): an in-process fake of the
* backend, so the `bench` build type can drive a real session screen -- the real
* [TranscriptSource], the real fold, the real paging -- with no server and no network permission.
*
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
* in this build compiles it in but never calls it, since Kotlin has no per-build-type source set
* that both [MainActivity] (which every variant compiles) and this can share without one.
*
* The design: [requestFromServer] and [Sse] talk to `https://$FIXTURE_HOST:$FIXTURE_PORT` through
* ordinary `java.net.URL`, exactly as they would talk to a real server. A
* [java.net.URLStreamHandlerFactory] registered once for the whole process intercepts every
* `https://` connection to that host and answers from this object's in-memory event log instead of
* opening a socket -- see BenchNetwork.kt. Everything above that (TranscriptSource, SessionScreen,
* the fold, uniqueItems) never learns the difference.
*/
object BenchFixture {
const val FIXTURE_HOST = "bench.fixture.invalid"
const val FIXTURE_PORT = 1
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
private const val BACKLOG_COUNT = 3202
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
/** The session id every bench run opens; nothing else in this build ever mints one. */
const val SESSION_ID = "bench-fixture-session"
/**
* The whole transcript, seq order, growing as [pushLive] is called during the streaming phase.
* Read by both the REST page handler and the SSE handler, so a page requested mid- stream and a
* live frame agree on what has "already happened" -- the same thing a real server's own
* transcript file guarantees.
*/
private val log = CopyOnWriteArrayList<Pair<String, SeqEvent>>()
/** The events not yet appended to [log] -- the streaming phase's own source. */
private var streamTail: List<Pair<String, SeqEvent>> = emptyList()
private val images = mutableMapOf<String, ByteArray>()
@Volatile private var loaded = false
/**
* Parses the bundled fixture once. Safe to call more than once; only the first does anything.
*/
@Synchronized
fun ensureLoaded(context: Context) {
if (loaded) return
val lines =
context.assets.open("transcript.jsonl").bufferedReader().readLines().filter {
it.isNotBlank()
}
val parsed = lines.map { it to parseSeqEvent(it) }
log.addAll(parsed.take(BACKLOG_COUNT))
streamTail = parsed.drop(BACKLOG_COUNT)
for (name in listOf("bench1.png", "bench2.png")) {
images[name] = context.assets.open(name).readBytes()
}
loaded = true
}
/** The events the streaming phase has left to send. */
fun remainingStreamEvents(): Int = streamTail.size
/** Sends the next fixture event onto the live log, as a real SSE frame would arrive. */
fun pushNextLiveEvent(): Boolean {
val next = streamTail.firstOrNull() ?: return false
streamTail = streamTail.drop(1)
log.add(next)
return true
}
/** Undoes [pushNextLiveEvent] and reloads the opening backlog, for running the bench twice. */
@Synchronized
fun resetToBacklog(context: Context) {
loaded = false
log.clear()
ensureLoaded(context)
}
fun fileBytes(name: String): ByteArray? = images[name]
/**
* Raw JSON lines with seq > [after], in order -- what an `/events?after=` connection replays.
*/
fun linesAfter(after: Long): List<String> =
log.filter { it.second.seq > after }.map { it.first }
/**
* One REST page: [fetchTranscript]'s `before`/`limit`/`after`, against the growing log. Ignores
* `coalesce` -- the fixture's own deltas are already split the way a real reply streams, and
* what the benchmark exercises is the fold and the paging, not the server's row-joining, which
* client-core's own port tracks separately (CLIENT_CORE.md).
*/
fun page(before: Long?, limit: Int, after: Long?): List<String> {
val upper = before ?: (log.lastOrNull()?.second?.seq?.plus(1) ?: 1L)
val candidates = log.filter {
it.second.seq < upper && (after == null || it.second.seq > after)
}
return candidates.takeLast(limit).map { it.first }
}
}
@@ -0,0 +1,181 @@
package com.example.aiapp
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLStreamHandler
import java.net.URLStreamHandlerFactory
import java.security.Principal
import java.security.cert.Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLPeerUnverifiedException
import org.json.JSONArray
/**
* Installs the process-wide interception [BenchFixture] needs. Idempotent and safe to call more
* than once; the JDK only allows [URL.setURLStreamHandlerFactory] to be called successfully once
* per process, and a second real call throws -- so this guards it rather than relying on every
* caller to remember.
*
* Scoped to [BenchFixture.FIXTURE_HOST]: any other `https://` URL falls through to the platform's
* ordinary handler, so this only ever changes behaviour for the one host the bench build invents.
*/
@Synchronized
fun installFixtureNetworkOnce() {
if (installed) return
installed = true
URL.setURLStreamHandlerFactory(
URLStreamHandlerFactory { protocol ->
if (protocol != "https") null
else
object : URLStreamHandler() {
override fun openConnection(url: URL): HttpURLConnection =
if (url.host == BenchFixture.FIXTURE_HOST) FixtureConnection(url)
else
// The bench build makes no other https call -- this factory is
// installed only in FIXTURE_MODE (MainActivity) -- so there is
// deliberately no delegate to a platform handler here: once a
// URLStreamHandlerFactory is installed there is no supported way to
// ask the JDK for its own default handler back, and re-entering this
// same factory for the fallback would recurse forever rather than
// reach one.
throw java.io.IOException(
"bench build's fixture network has no route to https host " +
"${url.host} -- only ${BenchFixture.FIXTURE_HOST} is served"
)
}
}
)
}
private var installed = false
/**
* Answers one request against [BenchFixture] instead of opening a socket. Implements just enough of
* [HttpsURLConnection] for [requestFromServer] and [Sse] to work unmodified: both only call
* `connect`/`disconnect`, set a handful of request properties they never need answered, and read
* `responseCode` and `inputStream`.
*/
private class FixtureConnection(url: URL) : HttpsURLConnection(url) {
private var input: InputStream? = null
private var writer: Thread? = null
override fun connect() {
if (input != null) return
input = route(url.path, url.query)
}
override fun disconnect() {
writer?.interrupt()
try {
input?.close()
} catch (_: IOException) {}
}
override fun usingProxy() = false
override fun getResponseCode(): Int {
connect()
return 200
}
override fun getInputStream(): InputStream {
connect()
return input!!
}
override fun getErrorStream(): InputStream? = null
// Nothing here reads any of these; implemented only because HttpsURLConnection declares them
// abstract. A fixture never negotiates real TLS, so each says exactly that rather than
// fabricating a plausible-looking certificate.
override fun getCipherSuite() = "none (bench fixture, no TLS)"
override fun getLocalCertificates(): Array<Certificate>? = null
override fun getServerCertificates(): Array<Certificate> =
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
override fun getPeerPrincipal(): Principal =
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
override fun getLocalPrincipal(): Principal? = null
/**
* [path] is `/sessions/{id}/...`; everything else this build's fixture is asked for is a bug.
*/
private fun route(path: String, query: String?): InputStream {
val params =
(query ?: "")
.split("&")
.filter { it.contains('=') }
.associate {
val (k, v) = it.split("=", limit = 2)
k to java.net.URLDecoder.decode(v, "UTF-8")
}
return when {
path.endsWith("/transcript") -> {
val lines =
BenchFixture.page(
before = params["before"]?.toLongOrNull(),
limit = params["limit"]?.toIntOrNull() ?: 80,
after = params["after"]?.toLongOrNull(),
)
val body = JSONArray(lines.map { org.json.JSONObject(it) })
ByteArrayInputStream(body.toString().toByteArray())
}
path.endsWith("/events") -> openEventsStream(params["after"]?.toLongOrNull() ?: 0L)
path.contains("/files/") -> {
val name = path.substringAfterLast("/files/")
val bytes =
BenchFixture.fileBytes(name)
?: throw IOException("bench fixture has no file named $name")
ByteArrayInputStream(bytes)
}
else -> throw IOException("bench fixture has no route for $path")
}
}
/**
* A live SSE body: [BenchFixture.linesAfter] replayed immediately, then polled every 50ms for
* anything [BenchFixture.pushNextLiveEvent] has added since -- the same shape a real backend's
* backlog-then-follow gives [Sse], just polled instead of woken, which is a fixture's business
* rather than something worth a condition variable for.
*/
private fun openEventsStream(after: Long): InputStream {
val pipeIn = PipedInputStream(1 shl 16)
val pipeOut = PipedOutputStream(pipeIn)
var sent = after
val thread = Thread {
try {
while (!Thread.currentThread().isInterrupted) {
val fresh = BenchFixture.linesAfter(sent)
for (line in fresh) {
pipeOut.write("data: $line\n\n".toByteArray())
pipeOut.flush()
sent = org.json.JSONObject(line).getLong("seq")
}
Thread.sleep(50)
}
} catch (_: InterruptedException) {
// disconnect() -- the ordinary way this ends.
} catch (_: IOException) {
// The reader side (Sse) closed its end.
} finally {
try {
pipeOut.close()
} catch (_: IOException) {}
}
}
.also {
it.isDaemon = true
it.start()
}
writer = thread
return pipeIn
}
}
@@ -0,0 +1,325 @@
package com.example.aiapp
import android.content.Context
import android.os.BatteryManager
import android.os.Process
import android.view.View
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.focus.FocusRequester
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* P0's scripted benchmark, run in-process instead of by a shell script: the phone has no usable
* system tracing (this-machine-android's skill) and no agent can drive it, so the same scroll loop
* and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over `ui-trace` are reproduced
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
* [BenchFixture]'s doc comment gives.
*
* **v2 (2026-09-06)**, asked for by Iris because the v1 fling was too gentle to stress-test the
* scroll path and said nothing about typing or the keyboard. Four phases now, each a slice of the
* same [FrameStats] recording ([FrameStats.markPhase]/[FrameStats.phaseLines] -- one recorder, not
* two): **fling** (real `FlingBehavior`, not `animateScrollBy`), **stream** (unchanged from v1),
* **type** (600 fixed characters into the real composer `TextFieldValue`, then deleted), and
* **keyboard** (five show/hide cycles). The exact constants below are also written into
* `docs/RUST.md`'s P0 box, "Benchmark v2 (2026-09-06)", so the iris half implements the identical
* spec -- changing a number here without updating that box makes the two apps measure different
* things while looking like the same benchmark.
*/
object BenchRun {
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, kept as the pre-v2 comparison. */
private const val CYCLES = 6
private const val SWIPE_PX = 900f
private const val SWIPE_MS = 200
private const val SWIPE_PAUSE_MS = 500L
/**
* Fling phase (v2): a real fling through the list's own [FlingBehavior], not `animateScrollBy`
* -- Iris's ask was that it "travel way faster" than the old tween-based swipe, and a tween can
* never exceed the distance it is told to cover in the time it is given, while a real fling
* decays from an initial velocity the way a finger flick does. 12,000 px/s is roughly a hard,
* fast flick on a ~420dp/in device (about 30 dp/ms-equivalent initial speed); chosen well above
* the ~4,500 px/s a moderate `animateScrollBy` swipe implies, so this phase exercises the fast
* end of what the platform's fling decay produces rather than the gentle one v1 measured.
*/
private const val FLING_VELOCITY_PX_S = 12_000f
private const val FLING_COUNT = 8
private const val FLING_SETTLE_CAP_MS = 3_000L
private const val FLING_PAUSE_MS = 300L
/** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */
private const val STREAM_EVENTS_PER_SEC = 20
private const val STREAM_SECONDS = 20
/**
* Type phase (v2): sentences built from long, multisyllabic words so the composer actually
* wraps across lines rather than fitting one, and long enough (600 chars) that the composer's
* own height grows over several frames, pushing the transcript above it upward the same way a
* real long message does. Exactly this string is also in `docs/RUST.md`'s P0 box so the iris
* half types the identical content.
*/
const val TYPE_TEXT =
"Benchmarking this transcript screen requires unusually long, multisyllabic words so " +
"wrapping and reflow are properly exercised: internationalization, " +
"counterproductiveness, disproportionately, incomprehensibility, " +
"deinstitutionalization, uncharacteristically, overenthusiastically, " +
"misunderstanding, straightforwardness, telecommunications, and interdisciplinary " +
"collaboration all push a narrow composer field to wrap across several lines while " +
"the transcript above is pushed upward by the growing keyboard-adjacent box, which " +
"is exactly what a real reader typing a long message sees happening now!!!"
private const val TYPE_CHAR_DELAY_MS = 50L
/**
* Keyboard phase (v2): five show/hide cycles, a second apart, is enough to see whether the
* transition is ever actually observed rather than being a one-off fluke either way.
*/
private const val KEYBOARD_CYCLES = 5
private const val KEYBOARD_SHOW_WAIT_MS = 1_000L
private const val KEYBOARD_HIDE_WAIT_MS = 1_000L
/**
* Scrolls, flings, streams, types and toggles the keyboard, then returns the extra report lines
* P0 asked for (per-phase travel/typing/keyboard counts, plus CPU time, peak RSS, battery
* current) -- [FrameStats] and [DebugStats] are reset first, exactly as `copyRenderReport`
* resets them, so the two accountings cover the same stretch of work.
*/
suspend fun run(
context: Context,
scope: CoroutineScope,
listState: LazyListState,
flingBehavior: FlingBehavior,
composerFocus: FocusRequester,
setComposerText: (String) -> Unit,
view: View,
): List<String> {
FrameStats.reset()
DebugStats.reset()
val cpuStartMs = Process.getElapsedCpuTime()
val battery = BatterySampler(context)
// Launched in the caller's scope rather than a fresh coroutineScope{} here, which would
// suspend this function until the sampler job ended -- and it only ends when told to.
val samplerJob = scope.launch {
while (isActive) {
battery.sample()
delay(1000)
}
}
val travel = runFlingPhase(listState, flingBehavior)
val sent = runStreamPhase()
runTypePhase(listState, composerFocus, setComposerText, view)
val keyboard = runKeyboardPhase(context, view)
samplerJob.cancel()
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
val rssLine = peakRssLine()
val batteryLine = battery.finish()
return listOf(
" fling: $FLING_COUNT flings out + $FLING_COUNT back at" +
" ${FLING_VELOCITY_PX_S.toInt()}px/s, travel $travel",
" scroll: $CYCLES cycles (${CYCLES * 4} swipes, legacy tween), " +
"streamed $sent/${STREAM_EVENTS_PER_SEC * STREAM_SECONDS} fixture events",
" type: ${TYPE_TEXT.length} characters inserted then deleted, one per" +
" ${TYPE_CHAR_DELAY_MS}ms",
keyboard,
" process CPU time over this run: ${cpuMs}ms",
rssLine,
batteryLine,
)
}
/**
* Phase 1: starting pinned at the newest end, [FLING_COUNT] flings away from it (toward older
* messages) through the list's real fling path, then [FLING_COUNT] back. Positive velocity here
* matches this list's existing scroll-offset convention (`TranscriptList`'s `reverseLayout`
* pins index 0 -- the newest item -- at the bottom; a positive scroll offset moves the viewport
* toward higher indices, i.e. away from the newest end and toward older content), the same sign
* the pre-v2 swipe loop below already used for its first two swipes.
*/
private suspend fun runFlingPhase(
listState: LazyListState,
flingBehavior: FlingBehavior,
): String {
FrameStats.markPhase("fling")
listState.scrollToItem(0)
val start = position(listState)
repeat(FLING_COUNT) {
listState.scroll { with(flingBehavior) { performFling(FLING_VELOCITY_PX_S) } }
waitForSettle(listState)
delay(FLING_PAUSE_MS)
}
val outward = position(listState)
repeat(FLING_COUNT) {
listState.scroll { with(flingBehavior) { performFling(-FLING_VELOCITY_PX_S) } }
waitForSettle(listState)
delay(FLING_PAUSE_MS)
}
val back = position(listState)
return "start=$start outward=$outward end=$back"
}
private fun position(listState: LazyListState) =
"idx=${listState.firstVisibleItemIndex}/off=${listState.firstVisibleItemScrollOffset}px"
/** Belt-and-suspenders on top of `performFling` already suspending until its own decay ends. */
private suspend fun waitForSettle(listState: LazyListState) {
val startedAt = System.currentTimeMillis()
while (
listState.isScrollInProgress &&
System.currentTimeMillis() - startedAt < FLING_SETTLE_CAP_MS
) {
delay(16)
}
}
/**
* Phase 2 (unchanged from v1): pinned to the newest end before streaming starts, the way
* stream-bench.sh's "Jump to latest" tap is -- a reply streamed into a list parked further back
* arrives off-screen and the report would show nothing happened.
*/
private suspend fun runStreamPhase(): Int {
FrameStats.markPhase("stream")
var sent = 0
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
BenchFixture.pushNextLiveEvent()
sent++
delay(1000L / STREAM_EVENTS_PER_SEC)
}
// Lets the last few deltas land and draw before the next phase starts.
delay(300)
return sent
}
/**
* Phase 3: focuses the real composer, shows the keyboard if the platform allows it, then types
* [TYPE_TEXT] one character at a time through the same `TextFieldValue` state a real keystroke
* updates, and deletes it the same way -- this is what exercises wrapping and the transcript
* being pushed upward, not a single big write.
*/
private suspend fun runTypePhase(
listState: LazyListState,
composerFocus: FocusRequester,
setComposerText: (String) -> Unit,
view: View,
) {
FrameStats.markPhase("type")
listState.scrollToItem(0)
composerFocus.requestFocus()
showIme(view.context, view)
// Lets focus and the keyboard's opening animation land before typing starts, so the frames
// this phase records are the wrap/reflow it is measuring, not the keyboard opening.
delay(300)
var typed = ""
for (ch in TYPE_TEXT) {
typed += ch
setComposerText(typed)
delay(TYPE_CHAR_DELAY_MS)
}
delay(200)
while (typed.isNotEmpty()) {
typed = typed.dropLast(1)
setComposerText(typed)
delay(TYPE_CHAR_DELAY_MS)
}
}
/**
* Phase 4: [KEYBOARD_CYCLES] show/hide cycles through the same [WindowInsetsControllerCompat]
* path a real IME toggle goes through, reporting how many of each were actually confirmed by
* [android.view.WindowInsets.isVisible] rather than assumed from having asked -- UI_RULES:
* never present an inferred value as a measured one. If the platform never shows it even once,
* this says so in words rather than reporting a phase with no keyboard in it.
*/
private suspend fun runKeyboardPhase(context: Context, view: View): String {
FrameStats.markPhase("keyboard")
var shown = 0
var hidden = 0
repeat(KEYBOARD_CYCLES) {
showIme(context, view)
delay(KEYBOARD_SHOW_WAIT_MS)
if (imeVisible(view)) shown++
hideIme(context, view)
delay(KEYBOARD_HIDE_WAIT_MS)
if (!imeVisible(view)) hidden++
}
return if (shown == 0) {
" keyboard: could not be shown ($KEYBOARD_CYCLES attempts, 0 confirmed visible)"
} else {
" keyboard: shown $shown/$KEYBOARD_CYCLES, hidden $hidden/$KEYBOARD_CYCLES" +
" (confirmed via isImeVisible)"
}
}
private fun controller(context: Context, view: View): WindowInsetsControllerCompat? {
val window = context.activity()?.window ?: return null
return WindowInsetsControllerCompat(window, view)
}
private fun showIme(context: Context, view: View) {
controller(context, view)?.show(WindowInsetsCompat.Type.ime())
}
private fun hideIme(context: Context, view: View) {
controller(context, view)?.hide(WindowInsetsCompat.Type.ime())
}
private fun imeVisible(view: View): Boolean =
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
private fun peakRssLine(): String {
val kb =
try {
File("/proc/self/status")
.readLines()
.firstOrNull { it.startsWith("VmHWM:") }
?.trim()
?.removePrefix("VmHWM:")
?.trim()
?.removeSuffix("kB")
?.trim()
?.toLongOrNull()
} catch (_: Exception) {
null
}
return " peak RSS: " +
(kb?.let { "${it}kB" } ?: "unavailable (/proc/self/status unreadable)")
}
}
/**
* Samples [BatteryManager.BATTERY_PROPERTY_CURRENT_NOW] (microamps) once a second for the length of
* a run. The property returns `Int.MIN_VALUE` on hardware that does not support it -- most
* emulators -- and that is reported as "unavailable" rather than folded into an average with the
* real samples, which would silently understate every number after it. See UI_RULES: never present
* an inferred value as a measured one.
*/
private class BatterySampler(context: Context) {
private val manager = context.getSystemService(BatteryManager::class.java)
private val samples = mutableListOf<Int>()
fun sample() {
val value = manager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
if (value != null && value != Int.MIN_VALUE) samples.add(value)
}
fun finish(): String {
if (samples.isEmpty()) return " battery current: unavailable on this device"
val meanUa = samples.sum() / samples.size
return " battery current: mean ${meanUa}µA over ${samples.size} samples" +
" (min ${samples.min()}, max ${samples.max()})"
}
}
@@ -1,15 +1,10 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -54,49 +49,3 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
* ends that tall would bow its sides. * ends that tall would bow its sides.
*/ */
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp) val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
/**
* A round button sized to the mark it draws.
*
* The composer's three actions -- attach, stop, send -- are single glyphs, and a pill's word-shaped
* padding around one glyph was width taken from the pickers beside it: with a long model name on
* the row, the permission mode ended up too small to hit. One diameter for all three, and it is the
* platform's minimum touch target rather than a button's shorter default height.
*
* [fill] null draws the outlined form, for the one of the three that does not act on the session.
*/
@Composable
fun CircleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
fill: Color? = null,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
val sized = modifier.size(CircleButtonSize)
if (fill == null) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
modifier = sized,
) {
content()
}
} else {
Button(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
colors = actionButtonColors(fill),
contentPadding = PaddingValues(0.dp),
modifier = sized,
) {
content()
}
}
}
/** How wide and tall one of those is; see [CircleButton]. */
val CircleButtonSize = 48.dp
@@ -160,7 +160,6 @@ private val FENCE_LANGUAGES: Map<String, Language> =
"shell" to Language.SHELL, "shell" to Language.SHELL,
"zsh" to Language.SHELL, "zsh" to Language.SHELL,
"console" to Language.SHELL, "console" to Language.SHELL,
"diff" to Language.DIFF,
"python" to Language.PYTHON, "python" to Language.PYTHON,
"py" to Language.PYTHON, "py" to Language.PYTHON,
"javascript" to Language.JAVASCRIPT, "javascript" to Language.JAVASCRIPT,
@@ -60,23 +60,3 @@ fun compactingLabel(seconds: Long?): String =
seconds < 60 -> "compacting ${seconds}s" seconds < 60 -> "compacting ${seconds}s"
else -> "compacting ${seconds / 60}m ${seconds % 60}s" else -> "compacting ${seconds / 60}m ${seconds % 60}s"
} }
/**
* How full the session is, as the status row says it.
*
* Three states, not two, and the third is the one that needed the words: a session whose occupancy
* is known and whose ceiling is not. That one keeps the bare figure, and a session with a ceiling
* gets both the reader can see which they are looking at. What must not happen is a missing
* ceiling drawn as a number, or as a proportion of some assumed window, which would be this screen
* inventing the very fact it does not have.
*
* A llama.cpp session always has one, since the window is a flag its own server was started with. A
* coding CLI's is the vendor's business and neither control protocol states it, so those keep the
* bare figure they have always had.
*/
fun contextLabel(held: Long?, limit: Long?): String =
when {
held == null -> "context unknown"
limit == null -> "context ${tokens(held)}"
else -> "context ${tokens(held)} / ${tokens(limit)}"
}
@@ -127,6 +127,20 @@ fun debugReport(
frames: List<String>, frames: List<String>,
accounting: List<String>, accounting: List<String>,
crash: String?, crash: String?,
/**
* P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on
* every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section
* heading only appears when there is something to put under it, so an ordinary copy from the
* render-report button reads exactly as it did before this existed.
*/
extra: List<String> = emptyList(),
/**
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
* [extra].
*/
phaseFrames: List<String> = emptyList(),
): String = buildString { ): String = buildString {
appendLine("ai-app render report") appendLine("ai-app render report")
appendLine(device) appendLine(device)
@@ -141,6 +155,11 @@ fun debugReport(
appendLine("transcript:") appendLine("transcript:")
transcript.forEach { appendLine(it) } transcript.forEach { appendLine(it) }
appendLine() appendLine()
if (phaseFrames.isNotEmpty()) {
appendLine("per phase:")
phaseFrames.forEach { appendLine(it) }
appendLine()
}
appendLine("frames:") appendLine("frames:")
frames.forEach { appendLine(it) } frames.forEach { appendLine(it) }
appendLine() appendLine()
@@ -152,6 +171,11 @@ fun debugReport(
appendLine("work since this was last copied:") appendLine("work since this was last copied:")
val work = DebugStats.lines() val work = DebugStats.lines()
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) } if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
if (extra.isNotEmpty()) {
appendLine()
appendLine("bench:")
extra.forEach { appendLine(it) }
}
} }
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */ /** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
@@ -40,28 +40,6 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
} }
} }
/**
* The rule between two replies that met with nothing said in between -- see
* [TranscriptItem.TurnBreak].
*
* No words and no colour. Every other divider here reports something that happened and is worth
* finding by scanning; this one only says "these are two", and it appears once per turn that
* started without anybody typing. Saying more was a screenful of announcements about background
* work the reader was not asking after -- one of them a whole shell command, drawn as centred prose
* because the words came from somewhere that had no reason to keep them short.
*
* The outline colour is the scheme's one for structure rather than for meaning, which is what this
* is. Inset from both edges so it reads as a separator between two rows rather than as the top edge
* of the one under it.
*/
@Composable
fun TurnBreakRow(modifier: Modifier = Modifier) {
HorizontalDivider(
modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 6.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
/** /**
* The mark a clear leaves. * The mark a clear leaves.
* *
@@ -61,24 +61,6 @@ sealed class SessionEvent {
data class AssistantText(val delta: String) : SessionEvent() data class AssistantText(val delta: String) : SessionEvent()
/** The durable value of the open assistant message, replacing its provisional deltas. */
data class AssistantTextFinal(val text: String) : SessionEvent()
/**
* The model's working, streamed the way its reply is: its own card, and deliberately not part
* of what the session said. Only a provider that actually streams its reasoning sends it.
*/
data class Thinking(val delta: String) : SessionEvent()
/**
* The thinking above this finished, having taken [ms].
*
* Measured by the driver, because only it can see when the model stopped: this app knows when
* an event *arrived*, and the last fragment of a block followed by a slow tool call looks
* exactly like thinking that went on that long.
*/
data class ThinkingDone(val ms: Long) : SessionEvent()
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent() data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
data class ToolUpdate(val id: String, val output: String) : SessionEvent() data class ToolUpdate(val id: String, val output: String) : SessionEvent()
@@ -126,26 +108,6 @@ sealed class SessionEvent {
val turnStart: Long? = null, val turnStart: Long? = null,
) : SessionEvent() ) : SessionEvent()
/**
* A line in the transcript this build cannot read: a kind a newer server wrote, or one an older
* server wrote that has since been dropped.
*
* [kind] is the word the line called itself, so the row can say what is missing rather than
* that something is. The server makes these when reading; no driver sends one.
*/
data class Unreadable(val kind: String) : SessionEvent()
/**
* Retired on 2026-09-06, hours after it was added: a background task finishing, which turned
* out to be a screenful of notices about work nobody was asking after.
*
* Kept because a transcript is append-only -- the sessions that ran a background task in that
* window have these lines for ever. It draws no row, which is the whole reason it is still
* named here rather than left to fall through to [Unknown]: that would draw a placeholder per
* background task, which is the same wall the row was removed for.
*/
object RetiredTaskNote : SessionEvent()
/** /**
* A command the session was asked to run on itself and cannot run yet. Resolved by * A command the session was asked to run on itself and cannot run yet. Resolved by
* [CommandSent] with the same id; a command that ran straight away has only that one. * [CommandSent] with the same id; a command that ran straight away has only that one.
@@ -155,9 +117,6 @@ sealed class SessionEvent {
/** The same command, handed to the session. */ /** The same command, handed to the session. */
data class CommandSent(val id: String, val text: String) : SessionEvent() data class CommandSent(val id: String, val text: String) : SessionEvent()
/** Provider-reported number of background tasks alive now. */
data class BackgroundTasks(val count: Int) : SessionEvent()
data class Status(val state: String) : SessionEvent() data class Status(val state: String) : SessionEvent()
/** /**
@@ -168,16 +127,6 @@ sealed class SessionEvent {
*/ */
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent() data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
/**
* Whether a picture can be sent to this session now, as the thing serving its model answered.
*
* Only a llama.cpp session says this, and it says it twice per model: unknown the moment the
* old one is left, then the loaded server's answer. It carries no row -- it is what the
* composer's photo button is drawn from, and a line in the transcript about a control is not
* something anybody asked after.
*/
data class Images(val images: ImageSupport) : SessionEvent()
/** /**
* What a turn cost, and how much the model was holding when it ended. * What a turn cost, and how much the model was holding when it ended.
* *
@@ -186,25 +135,7 @@ sealed class SessionEvent {
* so adding turns up would report a figure the session stopped being true of. Null where the * so adding turns up would report a figure the session stopped being true of. Null where the
* dialect did not say, which leaves the context unmeasured rather than unchanged. * dialect did not say, which leaves the context unmeasured rather than unchanged.
*/ */
data class UsageDelta( data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
val tokens: Long,
val context: Long?,
/**
* How fast the reply came out, where the provider measured it -- null everywhere else,
* which is most of them. Never worked out here: the time this app watched a reply arrive
* over includes the network and whatever the server was doing between tokens.
*/
val tokensPerSecond: Double? = null,
/**
* How long the provider spent reading the prompt before it began answering; null where
* nothing measured it. The same rule as [tokensPerSecond]: the provider's own figure, or
* nothing at all.
*/
val prefillMs: Long? = null,
) : SessionEvent()
/** How much context this session's model has, which is what [UsageDelta.context] is out of. */
data class ContextWindow(val tokens: Long) : SessionEvent()
/** /**
* A compaction that finished, and how much context it recovered. * A compaction that finished, and how much context it recovered.
@@ -238,8 +169,6 @@ sealed class SessionEvent {
*/ */
data class LimitReached(val resetsAt: Double?) : SessionEvent() data class LimitReached(val resetsAt: Double?) : SessionEvent()
data class AuthenticationRequired(val message: String) : SessionEvent()
data class Error(val message: String) : SessionEvent() data class Error(val message: String) : SessionEvent()
/** /**
@@ -276,9 +205,6 @@ fun parseSeqEvent(json: String): SeqEvent {
) )
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id")) "messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta")) "assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"assistantTextFinal" -> SessionEvent.AssistantTextFinal(body.getString("text"))
"thinking" -> SessionEvent.Thinking(body.getString("delta"))
"thinkingDone" -> SessionEvent.ThinkingDone(body.getLong("ms"))
"toolStart" -> "toolStart" ->
SessionEvent.ToolStart( SessionEvent.ToolStart(
id = body.getString("id"), id = body.getString("id"),
@@ -326,26 +252,19 @@ fun parseSeqEvent(json: String): SeqEvent {
body.getString("text"), body.getString("text"),
if (body.has("turnStart")) body.getLong("turnStart") else null, if (body.has("turnStart")) body.getLong("turnStart") else null,
) )
"unreadable" -> SessionEvent.Unreadable(body.getString("kind"))
"taskNote" -> SessionEvent.RetiredTaskNote
"commandQueued" -> "commandQueued" ->
SessionEvent.CommandQueued(body.getString("id"), body.getString("text")) SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text")) "commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
"backgroundTasks" -> SessionEvent.BackgroundTasks(body.getInt("count"))
"status" -> SessionEvent.Status(body.getString("state")) "status" -> SessionEvent.Status(body.getString("state"))
"settings" -> "settings" ->
SessionEvent.Settings( SessionEvent.Settings(
model = body.optString("model").ifEmpty { null }, model = body.optString("model").ifEmpty { null },
permissionMode = body.optString("permissionMode").ifEmpty { null }, permissionMode = body.optString("permissionMode").ifEmpty { null },
) )
"images" -> SessionEvent.Images(imageSupport(body.optString("images")))
"contextWindow" -> SessionEvent.ContextWindow(body.getLong("tokens"))
"usageDelta" -> "usageDelta" ->
SessionEvent.UsageDelta( SessionEvent.UsageDelta(
body.getLong("tokens"), body.getLong("tokens"),
if (body.has("context")) body.getLong("context") else null, if (body.has("context")) body.getLong("context") else null,
if (body.has("tokensPerSecond")) body.getDouble("tokensPerSecond") else null,
if (body.has("prefillMs")) body.getLong("prefillMs") else null,
) )
"compacted" -> "compacted" ->
SessionEvent.Compacted( SessionEvent.Compacted(
@@ -358,8 +277,6 @@ fun parseSeqEvent(json: String): SeqEvent {
SessionEvent.LimitReached( SessionEvent.LimitReached(
if (body.has("resetsAt")) body.getDouble("resetsAt") else null if (body.has("resetsAt")) body.getDouble("resetsAt") else null
) )
"authenticationRequired" ->
SessionEvent.AuthenticationRequired(body.getString("message"))
"error" -> SessionEvent.Error(body.getString("message")) "error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type) else -> SessionEvent.Unknown(type)
} }
@@ -374,21 +291,7 @@ fun parseSeqEvent(json: String): SeqEvent {
* first time the server grows a state, and the drift would be a reply that never splits or one * first time the server grows a state, and the drift would be a reply that never splits or one
* split mid-stream. * split mid-stream.
*/ */
fun sessionWorking(state: String): Boolean = fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
state == "running" || state == "compacting" || state == "loading" || state == "reading"
/** Whether the latest events still say this session needs an explicit provider login. */
internal fun authenticationPromptAfter(open: Boolean, event: SessionEvent): Boolean =
when (event) {
is SessionEvent.AuthenticationRequired -> true
// A later provider response proves an older authentication failure in a replayed page is
// no longer current. Without this, one old failure reopened sign-in after every later
// successful turn.
is SessionEvent.AssistantText,
is SessionEvent.AssistantTextFinal,
is SessionEvent.ToolStart -> false
else -> open
}
/** /**
* The context after [event], given what it was before. * The context after [event], given what it was before.
@@ -412,18 +315,3 @@ fun contextAfter(current: Long?, event: SessionEvent): Long? =
is SessionEvent.Cleared -> null is SessionEvent.Cleared -> null
else -> current else -> current
} }
/**
* The context window after [event], mirroring the server's `context_limit_after` for the same
* reason [contextAfter] mirrors its neighbour: the screen has to keep up between page loads.
*
* A window belongs to the process, so a session whose process has exited has none left standing,
* a session restarted on a different model would draw its occupancy against the old model's
* ceiling.
*/
fun contextLimitAfter(current: Long?, event: SessionEvent): Long? =
when (event) {
is SessionEvent.ContextWindow -> event.tokens
is SessionEvent.Status -> if (event.state == "exited") null else current
else -> current
}
@@ -1,127 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
/**
* A text field whose label is a line above it rather than a thing floating inside it.
*
* Every field in this app goes through here, and the reason is vertical space. Material's outlined
* field reserves room for a label that animates into its own border and pads the value by half a
* line top and bottom, so one setting costs the height of three lines of text to hold one. A form
* of ten settings is then a screen and a half of scrolling to read ten short answers.
*
* What is *not* shrunk is the value itself: it stays at body size, because what is expensive here
* is the framing rather than the text, and a field whose contents are smaller than the text beside
* it is a field the reader has to lean in to check. See UI_RULES on never shrinking text to fit.
*
* [hint] is what leaving it blank means, drawn inside the empty box. It had a grey line of its own
* above the box until 2026-09-21: a form of a dozen settings was then mostly explanation, and a
* setting should be a title and a box to type in. Inside, it costs no height and is gone the moment
* anybody types -- which is the trade, since that is also when somebody might look back at it.
*/
@Composable
fun LabelledField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
hint: String? = null,
enabled: Boolean = true,
/**
* How many lines the box is, at rest. One for a value; several for prose, where the reader is
* writing rather than filling in -- see `ParamKind::Prose`.
*/
lines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
/** What the keyboard's own action key does, which is usually what the button beside it does. */
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
Column(modifier.fillMaxWidth()) {
// Body size in the ordinary text colour, which is what a setting's label is where the
// control beside it is a switch or a picker. Smaller and greyer on the ones that are
// fields reads as two ranks of setting where there is one.
Text(label, modifier = Modifier.padding(bottom = 2.dp))
FieldBox(value, onValueChange, enabled, lines, keyboardOptions, keyboardActions, hint)
}
}
/** The box itself: the border, the padding, and the text. Shared so the two fields agree. */
@Composable
private fun FieldBox(
value: String,
onValueChange: (String) -> Unit,
enabled: Boolean,
lines: Int,
keyboardOptions: KeyboardOptions,
keyboardActions: KeyboardActions,
hint: String?,
) {
val interactions = remember { MutableInteractionSource() }
val focused by interactions.collectIsFocusedAsState()
// The focused border is the accent at the same width as the resting one. Growing it instead
// would move the text inside by a pixel on every focus, which is a whole form twitching as the
// reader moves down it.
val edge =
when {
!enabled -> MaterialTheme.colorScheme.outlineVariant
focused -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.outline
}
val shape = RoundedCornerShape(8.dp)
val style =
LocalTextStyle.current.merge(
TextStyle(
color =
if (enabled) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant
)
)
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
singleLine = lines == 1,
minLines = lines,
textStyle = style,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
interactionSource = interactions,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier =
Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest, shape)
.border(1.dp, edge, shape)
.padding(horizontal = 10.dp, vertical = 8.dp),
decorationBox = { field ->
Box {
// Under the text rather than beside it: the value is what the box is for, and a
// hint that pushed it sideways would move every character as somebody typed.
if (value.isEmpty() && hint != null) {
Text(hint, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
field()
}
},
)
}
@@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -43,60 +44,26 @@ import kotlinx.coroutines.withContext
/** /**
* Which machine's files to show, and where to start. * Which machine's files to show, and where to start.
* *
* A **machine**, not a session: a filesystem is a property of a machine, and a session only says * A **setup**, not a session: a filesystem is a property of a machine, and a session only says
* where it was working. That is what makes a second way in -- from the machines tab -- one more * where it was working. That is what makes a second way in -- from the setups tab -- one more
* caller rather than any new code here. * caller rather than any new code here.
*/ */
data class FilesTarget( data class FilesTarget(val setup: String, val setupName: String, val start: String)
val machine: String,
val machineName: String,
val start: String,
/** A document to open immediately; [start] remains the fallback directory. */
val file: String? = null,
)
/**
* The explorer opened on one file, wherever that file is.
*
* Its directory is what the reader lands in on the way back, which for a session's transcript is
* that session's own directory -- the log, the process record and the rest of what it wrote.
*/
fun fileTarget(file: FileOnMachine) =
FilesTarget(
machine = file.machine,
machineName = file.machineName,
start = parentOf(file.path) ?: "/",
file = file.path,
)
/** The explorer target for this session's machine, optionally opened on [file]. */
fun SessionSummary.filesTarget(file: String? = null) =
FilesTarget(
machine = machine,
machineName = machineName,
start = cwd?.takeIf { it.isNotBlank() } ?: "~",
file = file,
)
/** Where the explorer is: in a directory, or in one file. */ /** Where the explorer is: in a directory, or in one file. */
private sealed class Spot(val path: String) { private sealed class Spot(val path: String) {
class Dir(path: String) : Spot(path) class Dir(path: String) : Spot(path)
class Doc(path: String, val directory: Dir) : Spot(path) class Doc(path: String) : Spot(path)
}
private enum class UnsavedDestination {
Directory,
Session,
} }
/** /**
* The files on the machine a session runs on: browse them, read one, change one. * The files on the machine a session runs on: browse them, read one, change one.
* *
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps * Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
* flowing and coming back from a file costs nothing. Both back controls return from a file to its * flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
* directory. In a directory, Android back walks toward the session's project directory and closes * viewer, viewer to the directory it came from, directory to the one above -- and only closes from
* the explorer once it gets there; the header's back button closes it immediately. * where it opened.
* *
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is * Every directory that has been visited is kept for as long as this is open; the refresh glyph is
* how one gets asked again on purpose, and creating something refetches the directory it was * how one gets asked again on purpose, and creating something refetches the directory it was
@@ -105,82 +72,51 @@ private enum class UnsavedDestination {
@Composable @Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) { fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val initialDirectory = var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
target.file?.let(::parentOf)?.let { Spot.Dir(it) } ?: Spot.Dir(target.start)
var here by
remember(target) {
mutableStateOf<Spot>(
target.file?.let { Spot.Doc(it, initialDirectory) } ?: initialDirectory
)
}
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() } val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
var creating by remember { mutableStateOf(false) } var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below, // Edit mode and whether anything has been typed live here rather than in the pane below,
// because both ways out have to ask before discarding it. // because they are what back has to know about -- and back arrives from two places, the arrow
// and the platform's own gesture, which must mean the same thing.
var editing by remember { mutableStateOf(false) } var editing by remember { mutableStateOf(false) }
var dirty by remember { mutableStateOf(false) } var dirty by remember { mutableStateOf(false) }
var unsavedDestination by remember { mutableStateOf<UnsavedDestination?>(null) } var askUnsaved by remember { mutableStateOf(false) }
val here = stack.last()
fun go(spot: Spot) { fun go(spot: Spot) {
editing = false editing = false
dirty = false dirty = false
here = spot stack = stack + spot
} }
fun leave(destination: UnsavedDestination) { fun back() {
if (editing && dirty) { when {
unsavedDestination = destination editing && dirty -> askUnsaved = true
} else if (destination == UnsavedDestination.Directory) { editing -> editing = false
go((here as Spot.Doc).directory) stack.size > 1 -> {
} else { stack = stack.dropLast(1)
onClose() editing = false
dirty = false
}
else -> onClose()
} }
} }
suspend fun load(path: String, again: Boolean) { suspend fun load(path: String, again: Boolean) {
val existing = listings[path] if (!again && listings[path] is LoadState.Loaded) return
if (!again && (existing is LoadState.Loaded || existing is LoadState.Loading)) return
listings[path] = LoadState.Loading listings[path] = LoadState.Loading
listings[path] = listings[path] =
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
LoadState.Loaded(fetchDir(settings, target.machine, path)) LoadState.Loaded(fetchDir(settings, target.setup, path))
} }
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
} }
val projectDirectory = (listings[target.start] as? LoadState.Loaded)?.value?.path BackHandler(onBack = ::back)
val homeDirectory =
if (target.start == "~") projectDirectory
else (listings["~"] as? LoadState.Loaded)?.value?.path
fun systemBack() {
when (val spot = here) {
is Spot.Doc -> leave(UnsavedDestination.Directory)
is Spot.Dir -> {
val path = (listings[spot.path] as? LoadState.Loaded)?.value?.path ?: spot.path
when {
path == projectDirectory || path == target.start -> onClose()
projectDirectory != null ->
nextDirectoryToward(path, projectDirectory)?.let { go(Spot.Dir(it)) }
?: onClose()
else -> parentOf(path)?.let { go(Spot.Dir(it)) } ?: onClose()
}
}
}
}
// A file link can open without visiting the project first, but Back still needs to know where
// the project is. Home is likewise resolved by the machine rather than guessed on the phone;
// it is what lets every path beneath it be displayed with `~`, including over ssh.
LaunchedEffect(target.machine, target.start) {
if (target.file != null) load(target.start, again = false)
if (target.start != "~") load("~", again = false)
}
BackHandler(onBack = ::systemBack)
Box( Box(
Modifier.fillMaxSize() Modifier.fillMaxSize()
@@ -193,15 +129,14 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
when (val spot = here) { when (val spot = here) {
is Spot.Dir -> { is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading val state = listings[spot.path] ?: LoadState.Loading
// Navigate with the resolved path, but name anything under the machine's home // The resolved path once there is one: a directory opened as `~` is called what
// the way somebody working there would write it. // it turned out to be, not what it was asked for.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
val shownAt = tildePath(at, homeDirectory)
FilesHeader( FilesHeader(
title = baseName(shownAt), title = baseName(at),
path = shownAt, path = at,
machine = target.machineName, machine = target.setupName,
onBack = { leave(UnsavedDestination.Session) }, onBack = ::back,
) { ) {
GlyphButton( GlyphButton(
REFRESH_GLYPH, REFRESH_GLYPH,
@@ -217,7 +152,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
) )
} }
LaunchedEffect(spot.path) { load(spot.path, again = false) } LaunchedEffect(spot.path) { load(spot.path, again = false) }
DirectoryBody(state, directory = spot, onOpen = ::go) DirectoryBody(state, onOpen = ::go)
} }
is Spot.Doc -> is Spot.Doc ->
DocPane( DocPane(
@@ -226,26 +161,22 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
path = spot.path, path = spot.path,
name = baseName(spot.path), name = baseName(spot.path),
editing = editing, editing = editing,
homeDirectory = homeDirectory,
onEditing = { editing = it }, onEditing = { editing = it },
onDirty = { dirty = it }, onDirty = { dirty = it },
onBack = { leave(UnsavedDestination.Directory) }, onBack = ::back,
) )
} }
} }
} }
unsavedDestination?.let { destination -> if (askUnsaved) {
UnsavedDialog( UnsavedDialog(
onDiscard = { onDiscard = {
unsavedDestination = null askUnsaved = false
if (destination == UnsavedDestination.Directory) { editing = false
go((here as Spot.Doc).directory) dirty = false
} else {
onClose()
}
}, },
onCancel = { unsavedDestination = null }, onCancel = { askUnsaved = false },
) )
} }
@@ -254,7 +185,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
if (creating && dir != null && listing != null) { if (creating && dir != null && listing != null) {
CreateDialog( CreateDialog(
settings = settings, settings = settings,
machine = target.machine, setup = target.setup,
directory = listing.path, directory = listing.path,
onDismiss = { creating = false }, onDismiss = { creating = false },
onCreated = { path, isDirectory -> onCreated = { path, isDirectory ->
@@ -265,7 +196,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
load(dir.path, again = true) load(dir.path, again = true)
// A new file has nothing to look at, so it opens where it can be filled in. // A new file has nothing to look at, so it opens where it can be filled in.
if (!isDirectory) { if (!isDirectory) {
go(Spot.Doc(path, dir)) go(Spot.Doc(path))
editing = true editing = true
} }
} }
@@ -317,11 +248,7 @@ private fun FilesHeader(
* looks like a right one. * looks like a right one.
*/ */
@Composable @Composable
private fun ColumnScope.DirectoryBody( private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
state: LoadState<Listing>,
directory: Spot.Dir,
onOpen: (Spot) -> Unit,
) {
when (state) { when (state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp)) is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
is LoadState.Error -> is LoadState.Error ->
@@ -362,9 +289,7 @@ private fun ColumnScope.DirectoryBody(
name = entry.name, name = entry.name,
trailing = trailingOf(entry), trailing = trailingOf(entry),
onClick = { onClick = {
onOpen( onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path, directory)
)
}, },
) )
} }
@@ -432,7 +357,6 @@ private fun ColumnScope.DocPane(
path: String, path: String,
name: String, name: String,
editing: Boolean, editing: Boolean,
homeDirectory: String?,
onEditing: (Boolean) -> Unit, onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit, onDirty: (Boolean) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
@@ -457,7 +381,7 @@ private fun ColumnScope.DocPane(
state = LoadState.Loading state = LoadState.Loading
state = state =
try { try {
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.machine, path) } val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
if (got is FileContent.Text) draft = TextFieldValue(got.content) if (got is FileContent.Text) draft = TextFieldValue(got.content)
LoadState.Loaded(got) LoadState.Loaded(got)
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -480,7 +404,7 @@ private fun ColumnScope.DocPane(
try { try {
val written = val written =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
writeFile(settings, target.machine, path, draft.text, against) writeFile(settings, target.setup, path, draft.text, against)
} }
state = state =
LoadState.Loaded( LoadState.Loaded(
@@ -506,12 +430,7 @@ private fun ColumnScope.DocPane(
} }
} }
FilesHeader( FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
title = name,
path = tildePath(path, homeDirectory),
machine = target.machineName,
onBack = onBack,
) {
if (editing) { if (editing) {
if (saving) { if (saving) {
GlyphSpinner("Saving") GlyphSpinner("Saving")
@@ -608,9 +527,7 @@ private fun ColumnScope.DocPane(
scope.launch { scope.launch {
val fresh = val fresh =
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
fetchFile(settings, target.machine, path)
}
} catch (e: ApiException) { } catch (e: ApiException) {
saveError = e.message saveError = e.message
conflict = null conflict = null
@@ -654,7 +571,7 @@ private fun Note(text: String) {
@Composable @Composable
private fun CreateDialog( private fun CreateDialog(
settings: ServerSettings, settings: ServerSettings,
machine: String, setup: String,
directory: String, directory: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onCreated: (String, Boolean) -> Unit, onCreated: (String, Boolean) -> Unit,
@@ -674,8 +591,8 @@ private fun CreateDialog(
scope.launch { scope.launch {
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (isDirectory) createDir(settings, machine, path) if (isDirectory) createDir(settings, setup, path)
else createFile(settings, machine, path) else createFile(settings, setup, path)
} }
onCreated(path, isDirectory) onCreated(path, isDirectory)
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -692,11 +609,13 @@ private fun CreateDialog(
title = { Text("Create in ${baseName(directory)}") }, title = { Text("Create in ${baseName(directory)}") },
text = { text = {
Column { Column {
LabelledField( OutlinedTextField(
label = "Name",
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !busy, enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -759,35 +678,6 @@ internal fun parentOf(path: String): String? {
} }
} }
/**
* The next directory on the filesystem path from [current] to [destination], or null when there.
*
* Moving between two branches first walks upward to their common ancestor. Once [current] is that
* ancestor, the next press walks one segment down toward [destination]. Both paths are answers from
* the machine, so they are absolute and have no symlinks or `..` left to resolve here.
*/
internal fun nextDirectoryToward(current: String, destination: String): String? {
val here = current.trimEnd('/').ifEmpty { "/" }
val there = destination.trimEnd('/').ifEmpty { "/" }
if (here == there) return null
val beneathHere = if (here == "/") there.startsWith('/') else there.startsWith("$here/")
if (!beneathHere) return parentOf(here)
val next = there.removePrefix(here).trimStart('/').substringBefore('/')
return join(here, next)
}
/** A path as somebody on [home] writes it, leaving paths outside that home unchanged. */
internal fun tildePath(path: String, home: String?): String {
val at = path.trimEnd('/').ifEmpty { "/" }
val resolvedHome = home?.trimEnd('/')?.ifEmpty { "/" } ?: return at
return when {
at == resolvedHome -> "~"
resolvedHome != "/" && at.startsWith("$resolvedHome/") ->
"~${at.removePrefix(resolvedHome)}"
else -> at
}
}
/** What a path names: its last segment, with `/` naming itself. */ /** What a path names: its last segment, with `/` naming itself. */
internal fun baseName(path: String): String { internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/') val trimmed = path.trimEnd('/')
Loaded 100 of 400 files, more files were not shown because too many files have changed in this diff. Show more