Author SHA1 Message Date
iris-aiandClaude Opus 5 149a6838af Record the root unification, and where bounds stand
`0d03267` in iris unifies the root's layout path with every other widget's:
`Placing::WINDOW` is the box nobody drew and `Placing::ask` the one place a
box is decided. The leftover-as-a-minimum gap the log recorded for
`root_layout` is closed by it rather than left.

`SizeRule::{Min, Max, Clamp}` is on `layout/bounds` and not in #19. Every
hand-written test passes and the 400-seed scan does not, for a reason that is
a design question: a bound is the first rule whose effect depends on the box
its parent gives it, and the retained machinery hands a widget a box by paths
that never ask it again. The log records the four readings measured and what
each one's seeds were, so none of them is tried twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 14:35:28 -04:00
iris-aiandClaude Opus 5 6a92857079 Record the leftover minimum and the images in the trees
Two rounds past the ninth sweep: `b295c8b` reads a share under a parent that
divides nothing as a minimum, which is what Bryan said it means and what a span
already did, sharing one comparison with it; `2dba90b` grows images in the
generated trees, which is the only widget here whose hint is a length in pixels
and so the only one that reaches the case.

The dump baseline is 34,571 boxes now that the trees carry images, and a seed no
longer names the tree it used to -- the log says what that costs and where those
defects live instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 04:51:13 -04:00
iris-aiandClaude Opus 5 da6b003a1e Say what a leftover means where nothing divides it
Bryan's rule, generalising the max he gave for Scroll's content length on
2026-09-18: a leftover under a parent that does not divide is still a leftover
and acts as a minimum, so the length is max(box, px + rel*box) -- it fills the
rest where the fixed parts are shorter and overflows where they are longer.

Measured against a span, which implements it, and against the non-dividing path,
which drops the overflow in the one row where the fixed part is longer than the
box. Recorded with the cause and the window contract a fix needs; not fixed,
because what a length means is Bryan's to settle and nothing here mixes the two
outside the span's own test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 04:09:27 -04:00
iris-aiandClaude Opus 5 8adad924b9 Record the ninth sweep, over the widget vocabulary
Eight findings in iris `c2b8bf8`, the largest a widget's own size hint
overriding a length rule that hint cannot express -- which no widget here but
`Image` can reach, so the dump is unchanged and the case builds its own widget.

The lesson for the next round is in the handoff beside the seventh and eighth
sweeps' own: where a round fixed a class of defect one instance at a time, the
check belongs in the shared helper, which closes the class and tests the
instances that round said were already fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 03:57:11 -04:00
iris-aiandClaude Opus 5 0b60713806 Record the eighth sweep, and count the cold dump's boxes properly
The sweep over the 6,300 lines of tests #19 adds, and over the seventh
sweep's own commit. Seven findings, in iris `77ed7a2`.

Also corrects a number four earlier rounds carried: the cold dump over 400
depth-5 trees is 34,488 boxes, not 34,492. The run prints eight lines that
are not a box, so `wc -l` gives 34,496 and a partial filter gives whatever it
gives. Nothing any round concluded moves, because each compared two dumps
rather than their line counts -- but the handoff now says how to count, so
the next round does not have to guess which figure was right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 03:19:59 -04:00
iris-aiandClaude Opus 5 5b34d406c7 Record the seventh sweep, over the rigs and the sixth sweep's own fix
Six findings in iris f8aa0c5, and the seven things that tripped a rule and
were left. Three of those are named as work waiting for the pre-review-gate
pass, since they pre-date #19 and are outside its diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:54:22 -04:00
iris-aiandClaude Opus 5 29dd84e04e Point the fork's main at upstream too, so a fresh clone is right
The local fix left `origin/main` at the app's line, so a fresh clone of the
fork -- which has no `upstream` remote -- still resolved `main` to the wrong
base. Force-pushed to `ca2b4b2` with `origin/HEAD` following it; the app's
45 commits stay reachable through `app-pin`, which was pushed first.

Verified by cloning the fork: `git merge-base main <branch>` gives ca2b4b2
with only `origin` configured, the parley migration no longer appears in the
diff, and the pinned `32f6ad8` is still present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:21:10 -04:00
iris-aiandClaude Opus 5 5bf0491ee1 Name the app's Iris line app-pin, and let main mean the PR's base
The `iris` submodule's `main` was the fork's own line -- the app's 45
commits -- and not an ancestor of upstream's main, so it sat four merged
pull requests behind PR #19's actual base. Every obvious command was
therefore wrong in a way that looked plausible: `git diff main...` showed
#10, #12, #16 and #17 as the review branch's work, which is how the parley
text migration's undo path kept being reported as #19's.

The app's line is now `app-pin`, pushed to origin, and `main` tracks
`upstream/main`. `.gitmodules` follows `app-pin`, so `submodule update
--remote` cannot drag the pin onto upstream. The recorded commit is
unchanged at 32f6ad8; only the name it is reachable by has changed.

`origin/main` on the fork is left alone: moving it is a non-fast-forward on
a shared branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:17:08 -04:00
iris-aiandClaude Opus 5 adce0fcdbe Point the handoff at b7b8d09
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:10:22 -04:00
iris-aiandClaude Opus 5 0ace0e017c Record the sixth sweep, and the base every sweep should have used
PR #19's base is upstream/main at ca2b4b2, not the local `main`, which
tracks the fork's divergent line and sits four merged pull requests behind
it. Diffing against `main` shows #10, #12, #16 and #17 as this branch's
work; that is how the parley text migration's undo path kept surfacing in
sweeps. Git cannot record a pull request's base and `main` cannot be
renamed, so the handoff carries the note and the checkout carries
`git iris-base`/`git iris-diff`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:09:53 -04:00
iris-ai 734c521f5d Record the round's last two findings, and the review the old code is owed
`pre-submit-review` arrived after the Rust port and most of Iris were
written, so all of that went in unreviewed and no sweep has covered it. The
surface-texture defect is the argument for a pass of its own.

Nothing in Iris is kept for the app's sake either: the app is to be rewritten
against the new API rather than ported call by call.
2026-09-20 01:32:01 -04:00
iris-ai 718923c217 Record the fifth sweep and point the handoff at 7502176
The renderer, the text store, the input default and the harness, which the
four earlier rounds did not read, plus a second look at `redraw`.
2026-09-20 01:03:05 -04:00
iris-aiandClaude Opus 5 03c8933612 Record the quality sweep and point the handoff at 69ba915
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:21:13 -04:00
iris-ai 362191c3f3 Record the place.rs visibility round and point the handoff at 1ebd4d3 2026-09-19 23:52:13 -04:00
iris-ai 52a5c2f40c Record the scan results for the clarity sweep 2026-09-19 23:38:48 -04:00
iris-ai 55b51d9442 Record the clarity sweep and point the handoff at 7e2b4cd 2026-09-19 23:29:00 -04:00
iris-aiandClaude Opus 5 1442443e78 Move the round detail out of the handoff, and say when a scan is owed
The handoff said "three rounds" over four bullets and carried the detail of
each, which belongs in the findings log. It now names the four and their
commit ranges and points at `LAYOUT_LOG.md`, which gains the naming and
sweep round in full.

The check section split in two: the cheap gate runs on every change
including a rename, because the cold dump is the only thing that catches two
same-typed values being swapped; the seed scans run only when the change can
alter what layout computes, and never with the tree still moving under them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:27:34 -04:00
iris-aiandClaude Opus 5 d12cbcd499 Record the logic sweep and point the handoff at 6c84b6f
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:25:45 -04:00
iris-aiandClaude Opus 5 424f655d60 Say on_axis in the docs, and point the handoff at the tip
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:48:37 -04:00
iris-aiandClaude Opus 5 721a8fc50d Record the naming round in the handoff and LAYOUT.md
Iris PR #19's tip is 2807a92. LAYOUT.md's settled-design section named the
retired `PlaceDescAxis::within`/`shifted`/`sized` constructors and the
`axis`/`axis_mut` accessors; it now says the `_desc` chaining rule, the
`axis` lift, and that every pair is a struct of two per-axis values read
with `[axis]`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:25:35 -04:00
iris-ai 61a2f601bb Move the settled layout vocabulary into LAYOUT.md and prune the handoff
The handoff had grown a chronicle of what each commit renamed. What is
durable about it -- the three names, and the one argument a container uses
to say them -- is design, so it goes to `docs/LAYOUT.md` as its own section
and the handoff keeps a pointer plus where the branch stands.

`docs/PLAN.md`'s "frame/extent experiment" entry described a prototype in
retired words; it becomes a pointer, and the one finding it held that was
not recorded elsewhere -- why the extent-relative child-frame trial was
removed -- joins the failed hypotheses in `docs/LAYOUT.md`.
2026-09-19 18:06:28 -04:00
iris-ai 265ce6f829 Record the PlaceDesc argument in the handoff 2026-09-19 17:59:40 -04:00
iris-ai 56437dc90f Point the handoff at the current tip 2026-09-19 17:16:48 -04:00
iris-ai 1f9eea879f Record narrow_rel_base and the None argument 2026-09-19 16:55:57 -04:00
iris-ai 93a6830fcd Record rel_base and the container API, and sweep LAYOUT.md
`frame` is gone from the layout vocabulary in `docs/LAYOUT.md` too, since
leaving the design document in the old words defeats the rename; the
rendered-frame sense of the word is untouched. The `local == UiRegion::FULL`
item is dropped from the handoff: that comment was #18's and is not on the
review branch.
2026-09-19 16:35:02 -04:00
iris-ai 90400f50ac Record the region/placement sweep and prune the handoff
The `extent` rename landed as iris `5642f20`; the handoff had not listed it
as pending at all, and its item 2 named renames that are already upstream.
Say which vocabulary the app has still to absorb, drop the retired-clone
and archive chronicle, and strike the three clarity findings now done.
2026-09-19 14:51:07 -04:00
iris-ai f3c9ab8106 Record Iris review fixes, validation, and consolidated checkout 2026-09-19 13:53:12 -04:00
iris-aiandClaude Opus 5 67584cedda Record what the review of Iris #19 found
Two live defects at cadfba0, each with the repro that showed it and the
fix that answered it: a span places the slot after a collapsed leftover
child a gap early and a gap too long, and a child reused under a parent
that redrew keeps the mask index that parent replaced, so its drawing is
clipped where the subtree used to be. Neither is covered by a test, which
is why the suite is green over both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:20:59 -04:00
iris-ai 3ecd676bcf Land the layout repair's findings and retire the working log
`docs/LAYOUT_LOG.md` existed until the one-ask protocol landed. It has, as
Iris PR #19 replacing #18, so what has to outlive the log moves into
`docs/LAYOUT.md`: the hint rule and the one ask a span makes, the reported
shares that are left and the two-answers design that would remove them, what
the fuzzers reach and what they cannot, the hypotheses not to repeat, and the
counter and dump commands with the numbers they print now. The handoff becomes
what it is for -- where the work stands, which is waiting on a review.
2026-09-19 02:57:17 -04:00
iris-ai e4fada298d Record the step 5 result: hints skip the room ask, and the far pin is not the cost
Measured at a888717 and at the new f6242aa on wip/hint-first, with the
inset experiment recorded as tested and rejected. The plan is now review,
render and land.
2026-09-19 02:24:09 -04:00
iris-ai 1ef31bc9e7 Measure step 5's span pin and say what blocks the sound version
Re-measured the work counters at a888717 (unchanged from adbedaf), and
tested the handoff's claim about a positive span pinning `far`: without the
pin, seed 1's resize goes from 44 draws of 13 widgets to 20 of 9. The pin is
worth removing, and what stops a sound removal is that no `Part` can say
"from the cursor to the end of the box" without naming the box's length.
2026-09-19 01:29:16 -04:00
iris-ai 2fa734cd63 Record what the step 1 review found
The pre-submit review of the six one-ask commits found a scroll placing
content that fits into a window-length box rather than its viewport, and
three comments still calling window lengths frame lengths.
2026-09-19 01:24:22 -04:00
iris-ai 2a3072b90a Record the clean verification at a30971e
Every check in the table was run over the branch head with nothing else
running and no edit in flight: fmt, both clippy feature sets, the suite,
and all three fuzzers over sixteen cases.
2026-09-19 00:50:23 -04:00
iris-ai 5798877829 Mark the rename step done and say what it renamed
The record's boxes are part, asked and placed, LayoutHolds carries window
ranges beside its two pins, and what is left of the layout plan is the
retained cost and the review of the branch as one diff.
2026-09-19 00:40:53 -04:00
iris-ai 150245071f Record the renders and the counters for the frame-as-length step
Step 3 is done: against #18 the render set is byte-identical but for two
pixels of antialiasing in random and one padded block in text moving a
pixel, and a live resize of random matches a cold render at that size
exactly. The cost table is re-measured at adbedaf, where the fuzzer's own
Branch was found pinning the window and hiding what the change costs.
2026-09-19 00:35:42 -04:00
iris-ai 36ce66554b Record what making the frame a window length corrected
Step 2 of the layout plan is done in the experiment checkout, at 1512d84
and 23523ee. The handoff carries the commits and what is left; LAYOUT.md
carries the settled rule, which is now one coordinate unit with the frame
a length of it and both pins beside each other; the log carries what
implementing the plan corrected in it, including the four that would have
shipped as wrong layout and the two rig gaps that hid them.
2026-09-19 00:21:44 -04:00
iris-aiandClaude Fable 5.1 da1807664a Correct the frame-as-length step: Part::of is untouched, movement paths merge
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:59:38 -04:00
iris-aiandClaude Fable 5.1 e0b4308de4 Record Bryan's yes to region nodes as translations
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:58:30 -04:00
iris-aiandClaude Fable 5.1 aa1a85b316 Refine the frame-as-length step: one window unit, region nodes as translations
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:53:48 -04:00
iris-aiandClaude Fable 5.1 f33981e1e6 Record that padding is an inset and plan the frame-as-length representation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:47:12 -04:00
iris-aiandClaude Fable 5.1 1c52bc4e19 Record that a stack's sizing child decides its siblings' boxes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:17:40 -04:00
iris-aiandClaude Fable 5.1 9f988a8315 Record that both long fuzzers agree at 0ef87eb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:36:33 -04:00
iris-aiandClaude Fable 5.1 82c0ac4960 Record the seed 946 deferral and the fuzzer results at 3091fb8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:30:51 -04:00
iris-aiandClaude Fable 5.1 ef324aa88b Replace the step 3 plan with the one-ask protocol and record why it failed
The handoff now describes wip/one-ask at 3091fb8: a widget draws once in
the box it is asked in, its answer is placed by re-expressing the drawing,
and nothing is drawn again in a box an answer chose. The log records what
the step 3 plan got wrong -- it kept the second draw whose measurement
bit could not be defined, demanded a contract of the answer box, and
narrowed frames by region -- and what fuzzing the new protocol found: the
Part::Of composition dropping a pinned length, and a local redraw that
must be put back in the parent's answer box. The Pad frame rule is left
as a decision for Bryan with its cost stated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:23:49 -04:00
iris-ai 39978ff8ea Record decided-box oracle blocker 2026-09-18 15:07:15 -04:00
iris-ai 3eda6f00c8 Record decided-box contract blocker 2026-09-18 14:15:18 -04:00
iris-ai 280fad7472 Finish the transparent-frames handoff 2026-09-18 13:49:38 -04:00
iris-aiandClaude Fable 5.1 bdddb610c0 Trace the scroll circularity to apply_leftover rather than the protocol
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:25:14 -04:00
iris-aiandClaude Fable 5.1 b2484b900e Record that a leftover share narrows the frame like px and rel
Bryan's decision of 2026-09-18, which answers the Pad question and
makes the code's leftover filter in declared_lens a bug.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:14:09 -04:00
iris-aiandClaude Fable 5.1 4b976e240e Say what the two open transparent-frames seeds are and what they turn on
A second planning pass over 49cec82: both seeds are the rule that picks
which answer places a box, shown by shrunk trees, a draw trace and two
counter-experiments, with the recommendation they point at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:03:14 -04:00
iris-ai 152bed7ec4 Keep the transparent-frames plan as written beside what it landed as 2026-09-18 12:32:57 -04:00
iris-ai 1777a92205 Point the handoff at the reviewed head 2026-09-18 01:21:12 -04:00
iris-ai 398e4efaeb Record what transparent frames landed as, and what it found
The plan in this document is now a protocol in the code, on
`wip/transparent-frames` in the layout experiment: what it does, what the
rig measures it at, and the two things it ran into -- what a widget's answer
is the answer to when a container is drawn in two of its own boxes in one
frame, and what `Pad` should be now that a frame passes through. Both are
Bryan's to decide and both are at the top of Next.

The frame/extent prototype's chronicle goes with it: its four findings that
still hold and its two failed hypotheses stay, the rest was a record of how
the code got to a shape it no longer has.
2026-09-18 01:13:40 -04:00
iris-aiandClaude Fable 5.1 5c35efd498 Note that transparent leftover across a span is deferred
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 23:04:49 -04:00
iris-aiandClaude Fable 5.1 dc45f7bb3e Say what leftover means across a span in the transparent-frames plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 22:38:48 -04:00
iris-aiandClaude Fable 5.1 2bee0b4b53 Plan the transparent-frames layout protocol
Decided with Bryan on 2026-09-18: a child's frame is forwarded through
spans, stacks and scrolls and narrowed only by what is decided from above,
so rel is never a fraction of a self-sized box; placements are parts of
the parent's extent in frame units from its start, so a moved extent
re-places its children exactly; the placement pin becomes a symbolic
length; and a local redraw asks at the offer placement without deferring.
Lists the steps in order with the check for each and the failures that
mean stop rather than patch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 22:18:49 -04:00
iris-aiandClaude Fable 5.1 cb4557f1bc Trace the many-phase gap to local-redraw deferral, not the placement pin
The experiment's `many` loss against e44dea3 came from `redraw` refusing
any widget whose given frame is not its offer and marking its parent
instead, which under the frame/extent protocol chains to the root because
a span gives its children its own placement across itself as their frame.
Record the trace, the measured bound with the pin off, the local re-ask
branch `wip/local-reask` and its two remaining depth-6 counterexamples,
and point readers at this section before the earlier explanations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 20:52:15 -04:00
iris-aiandClaude Opus 5 12b7364998 Record the extent-child and ordered-walk landings, and where the gap really is
The placement pin was not most of it. Deleting the read prices no dependency
at all; expressing it properly is `e6ba570`, worth 19% of seed 13's `many`
and 20% of `resize`. What refuses most reuses is the extent contract -- 819
of 1,203 refusals a frame -- and that is the measuring ask and the placing
ask being two different extents, which `Span` is the remaining reader of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:33:33 -04:00
iris-aiandClaude Opus 5 384b6a1150 Price the frame/extent experiment against #18's head, and name the placement pin
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:02:02 -04:00
iris-ai ccc42f34f8 Document the causes and correction of layout invalidation cascades 2026-09-17 17:27:27 -04:00
iris-ai 32151f7f9f Record retained extent-frame progress and remaining layout gap 2026-09-17 16:41:06 -04:00
iris-ai 035c7f20e8 Record glyph emission savings and remaining layout costs 2026-09-17 16:18:18 -04:00
iris-ai 264dc4f0c2 Record frame-extent performance gains and alignment verification 2026-09-17 15:54:23 -04:00
iris-ai 8f1a5e0a46 Record exact layout equality and measured reuse improvements 2026-09-17 15:11:23 -04:00
iris-ai 0080bcbcba Document tested frame and extent prototype and remaining costs 2026-09-17 14:50:22 -04:00
iris-ai 3084491b9b Record the resize walk and the re-parenting defects, and what verified them
The first item on the plan landed as two commits on #18. The re-parenting
half turned out to be two defects rather than the predicted one -- the old
parent's child list erasing a subtree it no longer draws, and the settling
walk ordering by a stale depth -- and the `depth()` assertion the note
expected to catch it catches only the second.

`dirty_size_under` is deleted, so the section that argued for it is marked
superseded rather than removed: it says what the bottom-up walk is buying.
2026-09-17 13:12:55 -04:00
iris-aiandClaude Fable 5.1 8c16f9d0fd Record the frame-and-extent finding and the resize walk, and drop the measure/draw split
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 12:34:41 -04:00
iris-ai 0cdda1713f Record the span boundary landing and what the guard experiment showed 2026-09-17 05:13:30 -04:00
iris-ai be895375ec Record the bottom-up settle, and park two real defects on branches
Bryan's ordering replaces the stale-answer check with something the shape
of the frame makes impossible: nothing shallower is drawn while anything
deeper is dirty, so a parent never reads an answer that is about to move.

The other two are written up rather than landed. A stack takes its sizing
child's fraction twice, which no oracle can see because warm and cold are
wrong alike; the fix is three grid steps short of the tolerance. And
padding is to go outside what it pads with an `Inset` beside it, which one
unaccounted halving is holding up. Both are pushed as `wip/` branches.
2026-09-17 04:50:50 -04:00
iris-ai faa4e98311 Record the text-width defect, and that the depth-4 seeds are fixed
The two seeds a wider scan found were one defect, older than the branch: a
text reported a width rounded to the step below the line it measured, so
the box that came back to it broke differently. Two tolerances were holding
that together and both are gone.
2026-09-17 03:21:35 -04:00
iris-ai f64d6a8d4d Record the report rule, the stale-answer guard, and two open seeds
The `rel` reading Bryan decided landed, and with it a defect the retained
machinery had all along: a span that can overflow itself makes the two asks'
boxes differ far enough that a reused answer is read before the subtree
under it has settled.

Widening the fuzzers to 2000 seeds at depth 4 then found two warm/cold
divergences that predate both commits, so the branch is not correct and the
routine verification does not say otherwise. They lead "Next".
2026-09-17 02:58:25 -04:00
iris-aiandClaude Fable 5.1 4763e1a70d Record the snap as approved and CPU rounding as recommended
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:49:20 -04:00
iris-aiandClaude Fable 5.1 c0b0921973 Record the 2026-09-17 layout review in the handoff and prune its history
The review's verdict, Bryan's decisions on rel and on the measure/draw
split, the four findings with their measurements, the residual and the
shader snap, and the reordered queue. The chronicle of closed defects,
superseded verification lists and the cross-fixture tables the document
said not to compare are gone; the rules, lessons and current measurements
stay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:43:17 -04:00
iris-aiandClaude Opus 5 4e75019b9a Record the pixel path closing, and the mask a redraw inherits
A box in pixels is threaded down the draw now rather than composed back up
the move chain, which closes the offer chain breaking at a region node,
makes `Holds::through` an exact preimage, and retires `redraw`'s third ask.
The section that diagnosed the defect becomes the one that states the rule,
with what the plan got wrong recorded beside it: `px` cannot be stored on
`ActiveData`, because a resize that every `Holds` admits redraws nothing and
would leave it stale everywhere.

`AGREE_STEPS` stays 2, measured rather than assumed: one step passes the
100-seed oracle and fails the 400-seed shrinker on `resize-size` by
0.002 px, so the second step belongs to the resize path re-expressing a part
as a fraction of a box that changed length.

The review of that change found an older defect of its own -- a `Masked`
widget settled on its own panicked, since `redraw` handed it its own mask as
the one it inherited -- and the invariant that closes it is here too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:21:29 -04:00
iris-aiandClaude Fable 5.1 9a26862bce Record the frame rule, the decided axes, and the root entry's removal
Iris 5b78002 on #18: a child's answer comes back in the asker's frame, the
near-edge override is per-axis decided flags, and the window is no longer
a move entry. Also records the threading plan that closes the region-node
offer defect, and why widening the grid would not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 23:02:39 -04:00
iris-aiandClaude Opus 5 82646e8408 Record the narrow step left in the pixel path
It was in 5f16617's message and not here, which is the wrong place for it:
the handoff is what a session picking this up cold reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:09:34 -04:00
iris-aiandClaude Opus 5 9f825ed6fb Diagnose seed 220: the offer chain breaks at a region node
`5f16617` carries the composed box down the draw instead of walking back up
for it, which made exact composition a 3.6%/3.3% speedup rather than a cost.
The numbers are in "Performance" and the i64 item.

And seed 220 is diagnosed rather than left as a seed. `offered_region`
composes a widget's offer through its ancestors' offers except where the
parent is a region node, where it falls back to the node's **placed** box.
`Scroll` is where placed and offered differ -- it offers its content the
viewport and places it in a box as long as the content -- so the offer
becomes a function of the answer being re-derived and the old answer
confirms itself. It is the fourth defect this session to come down to the
offer against the placed box.

The write-up has the trace both ways, the fact that each child order settles
to its own fixed point so the tree is not unsettled, that marking everything
dirty converges warm onto cold, and the coordinate-frame problem that makes
the two plausible fixes awkward -- `offer` is in the parent widget's frame
and `region` in the parent node's, so neither answers "was this placed where
it was offered" alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:50:22 -04:00
iris-aiandClaude Opus 5 da2f93254e Record the exact composition, and the seed it stops one step short of
`45a7176` composes a box down its move chain on a grid fine enough to round
once instead of once a level. `Holds::through`'s two-route allowance is two
half steps where it was three, and it cost nothing: 1,880M instructions and
755M cycles against 1,908M and 760M, with all twenty-five work counters
identical. What paid for the widening is that a length composes on its own in
half the multiplies both ends of a box need, and that only the fraction needs
the extra room.

The i64 item in "Next" keeps the measurement that redirected it, since
aiming the work at the screen invariant would have been wasted.

One half step further is arithmetically available and not taken: shrinker
seed 220 on `reorder` then diverges warm against cold, by re-breaking a
wrapping text whose reported width moves a `Branch` onto its other subtree.
That is written up under the `Holds::through` invariant with its reduction
command and its ten-widget tree, and it is the next thing to chase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:31:37 -04:00
iris-aiandClaude Opus 5 b0c13b85a9 Record that the shader, not the grid, decides screen pixels
`d21a215` answers the API question: one widget keeps one length per axis, and
`Wrapper` -- what `WidgetPtr` already was -- is the second widget the second
length needs.

The `i64` composition item is rewritten around a measurement that changes
what it is for. `prelude.wgsl` decodes the raw counts into `f32`, walks the
move chain in floats, and lands every edge on a whole pixel with
`snap_floor`. Checked in the render rather than read off the source: there is
no partially covered column anywhere along `tabs`'s band of rounded rects, so
every box edge is hard, and the two `pad(10)` gaps are exactly ten pixels on
both sides of the truncating multiply. The screen invariant is therefore
already as good as integers allow, and exact composition cannot improve it.

What it can still buy is layout's own decisions -- the `Span` leftover
boundary, the queued clamp crossover, `Holds` validity, warm-against-cold
agreement -- with `Holds::through`'s two-route allowance as the test of
whether it worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:50:50 -04:00
iris-aiandClaude Opus 5 82c006cd59 Record four fixes that came out of one look at the tabs render
Iris #18 is at `e166e00`, and warm layout now agrees with cold on every rig
the repository has: all fifteen shrinker cases at 400 seeds of depth 5, and
the oracle at 1000 seeds of depth 6. Both pass for the first time. The
handoff's two standing leads, shrinker seed 288 and oracle seed 326, were one
defect and are gone.

What found them was asking why the `tabs` render differed, rather than
counting the pixels that differed:

- `d8ae9c3`: `redraw`'s second ask handed the final box over as if it were an
  offer, so a locally redrawn widget with its own alignment was placed twice.
- `2bc6bdf`: `Stack` and `Pad` overrode every child's alignment with the near
  edge, which is owed only where the box really is the child's own answer.
  `tabs`'s counters and `text`'s narrow panel match canonical `main` again.
- `38eba54`: `Holds::through`'s allowance was four half steps where three is
  the floor, and tightening it moved none of the rig's work counters. Why it
  cannot go to zero is recorded with it.
- `e166e00`: pins what the gap question was actually about -- a length in
  pixels is that many pixels at any nesting depth, and a share is one or two
  steps off, which is a trade for the row filling exactly.

Two corrections to this document. The bisect it reported for the
partial-repaint defect named a commit that cannot have caused it, and the
reasoning built on that was wrong. And its claim that `tabs` "differs only in
the widget count it prints about itself" was checked at `8220a78` and left to
cover `d3b0ebf`, which changed the render twice more.

One API question is open: `.width()` now overwrites what `.sized()` set,
since both are properties of one widget, so `tabs`'s centred square is a bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:18:26 -04:00
iris-aiandClaude Opus 5 a271795408 Record the partial-repaint fix, the truncating multiply, and what is left
Three commits on iris #18, now at `08c9d5a`.

`aea878d` fixes the ignored `redrawing_one_widget_does_not_move_what_scrolls_
around_it`, and the bisect this document reported for it was a red herring:
`95fb4f9` cannot be the cause, since the failing tree has no `Masked` in it.
The cause was `redraw` skipping a dirty widget's second ask whenever the
offer and the final box were the same *length*. It also closed three of the
four unreduced shrinker leads and two of the three depth-6 oracle seeds; the
two that are left are written up with their reductions.

`60367d8` stops `Holds::through` allowing for a rounding that did not happen,
which compounded down a chain of widgets each taking the whole of its parent.

`08c9d5a` makes `Fixed::mul` drop to the step below (Bryan: truncation is
preferable at this point), with the two short-circuits priced against the old
multiply. Measured against the float head in `iris-float-cmp`, which is the
comparison that was asked for: 1,800M instructions and 715M cycles against
1,761M and 688M, from 1,915M and 777M -- three quarters of the instruction
gap and two thirds of the cycle gap closed. The float pair's work counters
differ and the fixed-point pair's twenty-five are identical, so only the
second number is same-work-at-a-different-speed; both are in the table.

Also recorded: `cycles:u` returns garbage readings in this VM the way
`instructions:u` does, and a whole set of three can be garbage at once;
`tabs` is no longer a byte-identical render and why; and Bryan's idea of
composing in `i64` and narrowing only when storing, with what is already
protected, what is not, and the test that would say it worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:09:45 -04:00
iris-aiandClaude Opus 5 06f9ae7799 Measure what truncating costs instead of asserting it
This section said truncating "breaks the one rounding the grid is for" and
left it at that, which is the same fault it withdraws the fused multiply-add
for: a claim nobody ran.

Run, it fails one exactness test by a step -- a share reaching 199.99902 of a
200px row -- and does not fail the warm-against-cold oracle any harder than
the head, which gives the same three failures on the same seeds within
0.002px. So the 57M cycles are not obviously bought with layout drift, and
whether a share stopping a thousandth of a pixel short is worth them is a
decision rather than a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:00:14 -04:00
iris-aiandClaude Opus 5 bb5b79f2f6 Record the drift a partial repaint causes, and the rigs that reduced it
The oracle and the shrinker had separate generators and separate copies of
every scenario, so a failure one found could not be handed to the other: a
seed cannot be made smaller. Iris `98d4e98` splits growing a tree into
describing one and building it, and puts the cases in one place both rigs run.
Any failing seed now reduces with one command, which is written down here.

What it found first: marking one widget for redraw moves a span inside a
scroll 24px against a cold tree of the same state -- a whole child's height,
not a rounding. Five widgets, bisected to `95fb4f9`, where `Masked` began
reporting its box rather than its inner's size. Written out and ignored in
`tests/cases/unsettled.rs` until it is fixed.

Two claims in the verification list were withdrawn against a re-run rather
than an argument. The shrinker's "all five cases at 300 seeds of depth 5" does
not carry to a rig that runs fifteen cases over different trees and fails at
400. The 1000-seed depth-6 line does not hold at `5ed9e87` either, which fails
it on seed 40 after a resize -- so that run was never the clean baseline it
was quoted as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:59:33 -04:00
iris-aiandClaude Opus 5 10e46522b4 Withdraw the fused multiply-add, and measure what the grid costs
The section claimed what was left of fixed point's cost was "an integer
multiply with a rounding against a float fused multiply-add". Both halves are
wrong and the evidence is in the binaries.

`5ed9e87` contains no FMA instruction, and rebuilding it with
`-C target-feature=+fma,+avx2` still produces none: Rust does not contract
`a + b * c`, so `LerpUtil::lerp` is `subss`, `mulss`, `addss` on any target.
The float side never had the instruction the grid was being compared against.

Nor is the multiply the cost. Replacing `shift_round` in `Fixed::mul` with a
truncating shift -- all twenty-seven work counters diffed identical -- takes
43M instructions off and leaves cycles inside the noise. What moves cycles is
whether `UiSpan::within` inlines: the rounding and both short-circuits removed
together are worth 118M instructions and 57M cycles where separately they are
worth 61M and 8M, and `<UiSpan>::within` stops being a symbol. None of that is
shippable, but it is where to push.

Three other entries corrected against measurement rather than argument. The
division is not 0.00% of cycles -- that was `apply_scalar`; `Holds::through`
runs 2.23M `i64` divisions a 500-frame `many` and holds the divider busy 21.3M
cycles. `d75a1e2` and `4cbb242`'s short-circuits no longer earn anything now
that `Fixed` wraps. Branchless `shift_round` still loses, for the opposite
reason to the one recorded: it is the rounding's size, not its branch.

And the measurement posture: cycles spread 1-3% across nine runs of one
binary rather than 0.23%, `instructions:u` returns a garbage value in roughly
a quarter of readings, and an instruction count moves 0.5% across a rebuild of
identical source. Quote deltas against a baseline built beside the thing
measured.

Checked: `/home/bob/repos/iris-float-cmp` carries `5ed9e87` with
`Edits::fixed_branches` applied, and is recorded under "Rigs and reproduction"
so the comparison can be re-run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:07:02 -04:00
iris-aiandClaude Fable 5.1 663a95f4a2 Record what wrapping saved, and the rounding question left open
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 14:23:22 -04:00
iris-aiandClaude Opus 5 9ed90505b3 Withdraw the 3x, and record what a move actually costs
The performance section reported a cost the fixture had invented. `Branch`
picks which subtree to draw from a measured length, so seed 1 draws 88
widgets at `5ed9e87` and 115 at `bd6de71` -- the ms table was comparing
three and a half times the work. Measured on one tree, fixed point costs
about a fifth to a quarter.

Also corrects where the cost is. Cycles say the `i64` division is 0.00% of
`apply_scalar` and the multiply 1.5%; it is `saturating_add`, five
instructions with no `i32` vector form. Cycle counts are steady here to
0.23% -- it is wall time that varies 2x, not the counters -- and IPC is
unchanged across fixed point, so this is instruction count and not stalls.

Records the four wins taken (-7.4% cycles, -12% instructions), the four
things tried that made it worse, and the one lever left that needs a
decision rather than a measurement.

Also: the `Len`/`LayoutLen` split and `has_exact_size`; that `Scroll`
reports `LEFTOVER` rather than its content's size, which this said wrongly
in one place and rightly in another; and that the clipping `debug_assert`
holds mask-setters only, which is why a `LazySpan` does not trip it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:08:00 -04:00
iris-aiandClaude Opus 5 388a6a060d Bring the handoff to what the last session shipped
`OrthoSize` was deleted in `9d8415d` and the handoff still described it as
restored; the surviving rule is `Painter::ruled`, and the circularity lesson
is restated without the enum's names. `HOLDS_EPSILON_PX` went with
`39e4ca2`, the clipping `debug_assert` the alignment section asked for landed
as `95fb4f9` and is narrower than it asked, the glyph conversion queued under
"Next" landed as `11c55bc`, and `LAYOUT.md`'s stale sections are §4, §5 and
density rather than §2.

Also records why the grid costs 1.5x what floats did, from the disassembly
rather than from theory: `RegionRemap::apply_span` is 352 instructions with
two 64-bit `idiv`s in it, because a fixed-point rounding is code where a
float's is hardware, integer division has no vector form, and saturation
breaks the pairing the `f32` `Vec2` had.

And the finding that answers the queued clamp item's open half: a `Max` may
not take a `leftover`, because a cap must read the report a rule otherwise
makes moot, and a share puts the division into the same equation -- the
multiple-fixed-point failure seed 13 already punished once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:21:37 -04:00
iris-aiandClaude Opus 5 f83be016ba Update the layout measurements to the current head
Three more roundings and divisions taken out after measuring each, one
optimisation tried and reverted with its number kept so it is not tried
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:14:18 -04:00
iris-aiandClaude Opus 5 6081726314 Record what fixed point cost, and what it was
Three times slower in layout, and two thirds of that was a stacked child
redrawing twice a frame rather than the arithmetic. The counters are what
found it, once they could say "another layer" at all.

The performance table is rewritten around the fixed-point commits and in
milliseconds a frame, since the fixture it used has changed and the old
instruction counts cannot be compared across it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 04:07:27 -04:00
iris-aiandClaude Opus 5 2d860587a4 Bring LAYOUT.md §2 and §3 to what shipped
§2 described `Painter::place`, `move_offsets`, `resolved_region` and
`set_instance`; three of those four names no longer exist. What shipped is
the opt-in region node, a box rather than a translation, and remapping for
everything that did not opt in.

§3 described `Widget::on_resize` and its `Scale`/`Redraw`/`Translate`
answers, which are gone: the `Holds` interval says the same thing per drawing
rather than per widget type, and says how far. The 0.05 px comparison it
quoted is equality on the grid now.

The trait in §1 has two methods rather than three, and the line numbers it
cited have all moved; they are dropped rather than corrected, since the names
are enough to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:50:58 -04:00
iris-aiandClaude Opus 5 617331f913 Record what the last of the fixed-point imprecision is
Traced rather than guessed: the same box reached two ways, each rounding
where the other does not. Two of the three are fixed, the third is priced --
it costs the retained resize path, at a thousandth of a pixel.

`OrthoSize` is deleted rather than paused; the decision it was waiting on was
that a span asks whether a rule already answers for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:42:56 -04:00
iris-aiandClaude Opus 5 ddf746d8a4 Record fixed-point layout, and what it left open
Four commits on #18 put layout on a grid: `Fixed<SHIFT>`, then positions,
lengths, and the last of the pixels with `Holds`. The invariants that were
about float rounding are rewritten rather than annotated -- the 0.05 px
comparison is equality now, a move is a translation rather than a
re-expression, and the span boundary needs no margin.

What is left open is written down where the next session will look for it:
the remaining step of imprecision is one rounding between two ways of asking
how long a box is, and `OrthoSize` is paused on a decision rather than
started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 03:02:46 -04:00
iris-aiandClaude Opus 5 1a73aba1cd Record the leftover boundary, and close the reorder case
The shrinker's `reorder` case is no longer red: its two seeds were a span
deciding whether anything was left over on a box length its own answer had
produced, which comes back a few bits off. `5ed9e87` in `/home/bob/repos/
iris-pr18` moves that boundary by `HOLDS_EPSILON_PX` of room instead of
softening the comparison, so the validity range stays exact where generated
seed 16 needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:49:28 -04:00
iris-ai 76334cd5aa Update alignment handoff 2026-09-15 23:13:40 -04:00
iris-ai 085fc97334 Record why placement cannot be applied after a draw
Three attempts at "draw the widget, then move its drawing to where its
alignment says" failed, and the notes are worth more than the attempts: the
move is a change of coordinate frame, and no split of the stored state
carries it. Moving a widget's region with its drawing has a later local
redraw ask a differently rounded question, which crosses the one boundary
`Span` needs exact. Leaving the region alone lets the placed box accumulate,
because `try_reuse` returns a clean subtree's size without walking into it.
Both are measured.

Also recorded: that a container reporting a child's size while handing it a
bigger box places that content twice, which is what the align override is
for; that a widget must not report more than it occupies, which is why
`Scroll` now fills both axes; and Bryan's decision that alignment is one
fraction per axis rather than four directions, with the middle as the
default because the edges assume a direction.

The alignment work is in the working tree at `8220a78` and not committed.
Its one red case, and the four things queued behind it, are listed.
2026-09-15 21:26:04 -04:00
iris-ai cf7b33da39 Record why a move recomposes, and settle alignment's ownership
The measurement is the part worth keeping: adding an offset to a moved
subtree's stored regions drifts linearly and without bound, so the fraction
path is there for accuracy, not generality. `tests/drift.rs` on the Iris
branch pins it.

Built-in alignment and size moves onto #18 rather than waiting for it, with
ownership agreed -- properties on the `region_node` mechanism, a declared
length winning per axis, and a size rule that carries min/max/clamp so a
clamp is resolved where the box is decided instead of by a widget reading
its own pixels.
2026-09-15 19:27:30 -04:00
iris-ai 8db0969d9d Update region-node handoff 2026-09-15 18:03:37 -04:00
iris-ai 7b8b43a7d2 Record explicit span orthogonal sizing 2026-09-15 16:41:03 -04:00
iris-ai 3a5d96a8ae Update Iris extraction handoff 2026-09-15 16:07:17 -04:00
iris-aiandClaude Opus 5 af751599f6 Make the handoff enough to start the leftover work from cold
Rewrites the opening as state rather than a chronicle of the last few
commits, and gives the parked rule its own section: what the owner asked
for in her words, the half that is written and works with the code in
it, the four ways of asking that were measured and what each did, the
seed that shows it most clearly, and the design question underneath --
which is the same one holding OrthoSize. Also folds the declared-length
rule into the invariants, brings the vocabulary to leftover, says the
suite is eight seconds and must stay that way, and writes out the image
replay rather than pointing at a file in /tmp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:32:47 -04:00
iris-aiandClaude Opus 5 df55e181d9 Record the rename, and what four ways of asking about leftover did
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:27:08 -04:00
iris-aiandClaude Opus 5 a128648bd2 Record the declared-length fix, and park the rest-with-no-leftover rule
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:52:04 -04:00
iris-aiandClaude Opus 5 324f0f02ef Sharpen the declared-length defect, and what a negative share does
Only rel doubles: apply_rest turns rest into the whole box and px
coincides. Aligned and Scroll honour a hint the same way Span does, so
all three double it. Also corrects the note about a fractional hairline,
which fails because a fraction is a fraction of its own box rather than
through any rounding, and records that a span whose shares run out walks
its children backwards instead of overflowing forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:44:55 -04:00
iris-aiandClaude Opus 5 e6b9cb29b7 Record the doubled declared fraction, and what a squeezed span does
The text reference render is not byte-identical after all: a declared
rel or rest length is applied by the span and then again inside SetSize,
so .width(rel(0.5)) draws its child at a quarter. Also replaces the
example that measured one-pixel lines with what the tests now say, since
a span short of room overlaps its fixed lengths rather than collapsing
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:32:12 -04:00
iris-aiandClaude Opus 5 89d20c31dc Re-measure #18 against the code it replaces, and retire the tabs divergence
The tabs difference does not reproduce at f61e893 against upstream/main
built in its own worktree, so nothing is waiting on a decision. Records
the instruction counts, frame times and memory taken today rather than
mid-branch, and what a one-pixel line and a one-pixel gap do under the
chain, which is the question the pixel difference was really about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 02:56:11 -04:00
iris-aiandClaude Opus 5 155b7650a9 Name the handoff doc in AGENTS.md and generalise it to HANDOFF.md
A fresh session had no way to learn the handoff existed: AGENTS.md named
only docs/PLAN.md, and the filename tied the document to one piece of work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 02:33:53 -04:00
iris-aiandClaude Fable 5.1 39409b6683 Record the derived-box rule and what the fuzzers found in the handoff
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:17:29 -04:00
iris-aiandClaude Opus 5 98dcb31c23 Bring the handoff up to date with what the fuzzer found
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:42:13 -04:00
iris-aiandClaude Opus 5 1ffe6ea067 Note the immediate-mode path Iris proposed, and what it would not catch
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:02:06 -04:00
iris-aiandClaude Opus 5 9bfda08f85 Split the performance section so a fresh reader can find the settle order
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:30:15 -04:00
iris-aiandClaude Opus 5 f486f00ec9 Bring the state of #18 and what a frame is made of up to date
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:29:55 -04:00
iris-aiandClaude Opus 5 cb2942bf58 Record that dropping the settle order costs more than keeping it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:27:02 -04:00
iris-aiandClaude Opus 5 2a40aa01db Record what carrying the depth cost and what is left
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:10:39 -04:00
iris-aiandClaude Opus 5 ba97265191 Record why an arbitrary settle order returns a stale size
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:57:26 -04:00
iris-aiandClaude Opus 5 d0c80a2037 Record that picking any dirty widget does not work, and what it costs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:37:51 -04:00
iris-aiandClaude Opus 5 592cc808ca Record both halves of the text fix, and what dirtying many widgets shows
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:22:14 -04:00
iris-aiandClaude Opus 5 60bf2568f8 Say what the two measurement worktrees are for now
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:52:23 -04:00
iris-aiandClaude Opus 5 d50e791130 Record what the shaping store cost and what it bought
Both gestures, since two widths in turn flatters a store and a drag does
not, and the memory beside the time: the question that decided the shape
of it was whether it is a fixed cost or one per text widget.

Bring LAYOUT.md's §1 and §2 to what shipped. A widget returns its size
rather than recording it, a slot carries a box rather than a
translation, and the per-axis answer is `OnResize` rather than
`is_size_independent` -- and the constraint the position chain replaced
was an agent's, which the text now says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:51:34 -04:00
iris-aiandClaude Opus 5 2acfcf51aa Compare the retained layout with the old code on one tree
The random generator grew during the work, so the seed that names a
1061-widget tree in the pre-#16 rows names a 144-widget one now. Every
"still N times the old resize" here was measured across that change and
says nothing; a hand-written fixture that compiles on both revisions
says the old code is 2.2x cheaper and puts a paragraph outside the
output, for two reasons it records as deliberate.

Record what a resize frame is actually spent on -- 63% text, 0.9%
draw_inner -- and what removing the single-entry shaping and placement
caches measures at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:10:14 -04:00
iris-ai ecce4448f0 Document per-axis retained layout 2026-09-14 17:49:42 -04:00
iris-ai 974d6c7a52 Document coalesced resize layout 2026-09-14 17:06:41 -04:00
iris-ai 986f98380d Document retained layout diagnostics 2026-09-14 16:42:42 -04:00
iris-ai ccc75d24fe Separate layout cost from draw counts 2026-09-14 16:25:54 -04:00
iris-ai cb1cfff0bf Record retained layout optimization results 2026-09-14 15:58:10 -04:00
iris-aiandClaude Opus 5 38f6f9ac07 Record what the layout actually costs, and what of it is necessary
The random tree at depth 7 measured against the two revisions before it: the
GPU pass is a tenth of a millisecond everywhere, and every difference is the
CPU laying out. A repaint of one leaf costs 1313 draws and 6.5 ms where the
pre-#16 code cost one draw, because `redraw` escalates to the top size reader
before knowing whether the size changed at all. Drawing first and escalating
only on a different size takes that to one draw and takes a resize below the
pre-#16 number, but misplaces four widgets in the resize case, so it is the
owner's to call.

Also records the measure-by-drawing multiplier -- 1.3x the drawn widgets at
depth 4, 5.2x at depth 8 -- and that scrolling is now part of the random
trees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:36:11 -04:00
iris-ai ba275fc966 Rewrite the handoff around what is true now: the chain's invariants, the open pixel decision, and what is left 2026-09-14 14:07:11 -04:00
iris-ai d5486b124a Record the random tree's padding nodes and span add/remove scenarios 2026-09-14 13:48:53 -04:00
iris-ai e442481a30 Record the two reuse defects the random trees found, their fixes, and the one-pixel tabs difference left for Iris to call 2026-09-14 13:31:46 -04:00
iris-ai 19b9ca7413 Record #18's three agreed changes, the measurement, and the wrapping-text divergence the random-tree rig found 2026-09-14 13:06:49 -04:00
iris-aiandClaude Fable 5.1 5959d58e44 Record the agreed plan after #18: opt-in slots, a rewalk stretch, Span saying Scale
And the open question beside it: a slot that carries a box rather than a
translation, which would make a move and a stretch of a Scale subtree
one write each. To be measured with chain_cost before it is chosen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 11:39:45 -04:00
iris-ai c683e62e4f Bring the handoff up to date: #18's state, the open question, and the rigs 2026-09-14 11:08:59 -04:00
iris-ai e90b026969 Record the chain-depth measurement and the question it opens 2026-09-14 03:32:03 -04:00
iris-ai 836c222f0c Record #16 and #17 merged, and #18 opened for the position chain 2026-09-14 03:16:29 -04:00
iris-ai 9f039d6440 Record that Remap is temporary, to be deleted by region chaining 2026-09-14 02:44:58 -04:00
iris-ai d373a1b05c Record the Remap contract and the measured resize cost 2026-09-14 01:27:48 -04:00
iris-ai 270fe66802 Record the resize change, and why the position chain moved up 2026-09-14 01:09:33 -04:00
iris-ai 0aaa1f92db Record both review rounds answered, and what alignment now follows 2026-09-14 00:40:52 -04:00
iris 80527ff843 Reorder: alignment before the chain, and what the chain must fix 2026-09-13 23:51:48 -04:00
iris 8e6d709ff3 Record #17, and queue the render state behind Rc<RefCell> 2026-09-13 23:10:48 -04:00
iris 0f24d3ac23 Record #16, the position chain, and why the hint shortcut is not the fix 2026-09-13 23:05:47 -04:00
iris e04f873453 Record all three merged, and what comes next 2026-09-13 22:07:57 -04:00
iris 9c85ea4864 Correct how the work is sequenced, and record #15 opened 2026-09-13 20:44:46 -04:00
iris f3ba05d446 Record #14 merged, #12 reworked, and the harness slice 2026-09-13 20:21:11 -04:00
irisandClaude Opus 5 d0606f46ec Rewrite the extraction handoff as current state
Incremental edits had spliced the opening section into two half-sentences and
left it saying main was at the commit before last. It is now written as where
things are: two open PRs, the harness as the next slice, the renderer's
invariants rather than the story of how they arrived, and the three render
checks described rather than pointed at a script in /tmp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:45:08 -04:00
irisandClaude Opus 5 c2b1c07895 Record #14
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:35:08 -04:00
irisandClaude Opus 5 8d9680547a Record the input restructure as a slice, and what guards it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 -04:00
irisandClaude Opus 5 69b40d555e Record the #12 self-review
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:10:15 -04:00
irisandClaude Opus 5 ba678e97f3 Record #11 merged and #13 opened
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:59:19 -04:00
irisandClaude Opus 5 c5a10ac845 Record #11 as approved
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:56:16 -04:00
irisandClaude Opus 5 a009261fae Record the wgpu 30 upgrade and why it is not pushed yet
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:42:17 -04:00
irisandClaude Opus 5 64d8042f52 Record the primitive render dispatch and how it was measured
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:30:52 -04:00
irisandClaude Opus 5 912602d52b Order the remaining Iris slices by how fundamental they are
The owner asked for library updates and core framework changes first, so code
is only written once, and for tests to arrive with a slice rather than as
scripts outside the repository. So wgpu 28 -> 30 and the headless rig and
harness come before any of the feature slices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:45:34 -04:00
irisandClaude Opus 5 2937a48133 Record the seventh #11 review
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:44:21 -04:00
irisandClaude Opus 5 8beb461d83 Record the sixth #11 review
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:23:54 -04:00
irisandClaude Opus 5 43f6bda92d Record the fifth #11 review
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:53:00 -04:00
irisandClaude Opus 5 8daf1bcac1 Record the fourth #11 review and the agreed texture design
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:15:59 -04:00
iris 26f86bc6f7 Rewrite the Iris extraction handoff for a fresh session
Current as of PR #11's fourth review, which is deliberately unread: the
first instruction is to fetch it. Records what #11 became over three
rounds of review, since that shape is what the new review is about, and
where the headless rig actually lives, since a rendering claim cannot be
verified without it.

Drops the finished detail -- PR #10's contents, the source-history
archaeology that has been consumed -- and points at the machine-wide
pre-submit-review gate rather than restating it.
2026-09-13 15:50:09 -04:00
iris bc555f9ded Record the #11 review and the pre-submit gate in the handoff 2026-09-13 12:48:24 -04:00
iris a9d63babe4 Record the two open Iris extraction PRs in the handoff 2026-09-13 04:01:47 -04:00
iris e774b465fd Document Iris extraction handoff 2026-09-13 03:45:02 -04:00
iris 50d214e5b1 Move Iris to its standalone repository 2026-09-12 23:16:47 -04:00
iris 715c86e7e2 iris: eliminate retained-frame heap churn 2026-09-12 22:44:03 -04:00
iris badd758d9b iris: retain next-frame staging allocation 2026-09-12 22:11:38 -04:00
iris 45cdfed293 iris: keep redraw comments current 2026-09-12 22:06:13 -04:00
iris 77b39a97e3 iris: process dirty widgets one at a time 2026-09-12 22:05:06 -04:00
iris b1416d86bf iris: drive animation from painter frame time 2026-09-12 21:37:47 -04:00
iris 9fe6aca1f1 iris: make span compaction explicit 2026-09-12 21:18:09 -04:00
iris 9097744a7c iris: give alignment examples full width 2026-09-12 21:03:27 -04:00
iris adb8276ef4 iris: keep text overflow masks horizontal 2026-09-12 20:59:54 -04:00
iris 93c106e2f8 iris: clip ellipsized text at cluster boundaries 2026-09-12 20:54:26 -04:00
iris 1f0a7b5f94 iris: add positioned text overflow 2026-09-12 19:58:02 -04:00
iris cb5f11d21a iris: separate layout allocation from lengths 2026-09-12 19:35:18 -04:00
iris 90dffce514 iris: add replaceable glyph atlas buckets 2026-09-12 18:10:55 -04:00
iris c97df72015 iris: share resource handle bookkeeping 2026-09-12 17:13:49 -04:00
iris a8093b002b iris: move text state into shared resources 2026-09-12 14:18:12 -04:00
iris 4cbe7baea0 iris: make font families application-named strings 2026-09-12 13:09:31 -04:00
iris bdc9c914c4 iris: load application-owned fonts 2026-09-11 14:14:41 -04:00
iris f7e7950908 Reuse Android builds and discard staging 2026-09-11 13:56:32 -04:00
iris 779f5c63d3 Organize Iris support files 2026-09-11 13:06:52 -04:00
iris 0ead062379 Rename app_init to android_init 2026-09-11 12:36:50 -04:00
iris aa33d55a70 Default DesktopApp to the standard UI state 2026-09-11 12:31:52 -04:00
iris 6efd0cf0aa Align desktop initializer arguments with builders 2026-09-11 12:30:33 -04:00
iris ecf74055c7 Simplify Iris app initialization and task updates 2026-09-11 12:28:33 -04:00
iris 8218e84b62 Run Iris examples on desktop and Android 2026-09-11 11:33:04 -04:00
iris df1290904b Add Iris Android APK tooling 2026-09-11 03:44:06 -04:00
iris e137f38a5d Return the tabs demo to one example 2026-09-11 02:18:34 -04:00
iris 213a0debb7 Use one standard Iris resource bundle 2026-09-11 02:09:17 -04:00
iris d8bb1699a8 Make the Rust client the sole app 2026-09-11 01:18:24 -04:00
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
417 changed files with 19945 additions and 66900 deletions

No files matched your search

-6
View File
@@ -1,6 +0,0 @@
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
# every crate in this repo into one workspace -- they are deliberately
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
# from the repo root runs xtask/src/main.rs directly.
[alias]
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
+2 -28
View File
@@ -17,9 +17,7 @@ label: "AI Sessions",
// three constants -- there is nothing here worth spawning a process for. // three constants -- there is nothing here worth spawning a process for.
resources: Ron("resources.ron"), resources: Ron("resources.ron"),
// The two halves this checkout produces: the server a phone talks to, and // The server and Rust app build in parallel.
// the app that talks to it. They are built in parallel -- this list is the
// set, not a sequence, so nothing here should be read as an order.
components: [ components: [
Server( Server(
name: "server", name: "server",
@@ -39,15 +37,8 @@ components: [
), ),
Apk( Apk(
name: "app", name: "app",
// Release first: the first mode is the default, and the phone runs
// the release build -- a debuggable one runs Compose at a fraction
// of the speed. Each command below is run with the chosen mode as
// its last argument, which is exactly build-apk.sh's interface.
modes: ["release", "debug"], modes: ["release", "debug"],
// Resolved against this directory, and run in `app/` -- the script build: "./build-apk.sh",
// cds to its own directory anyway, so the cwd is here to say where
// the app is rather than because the build needs it.
build: "app/build-apk.sh",
cwd: "app", cwd: "app",
// The Enroll button in this component's settings: prints the link // The Enroll button in this component's settings: prints the link
// that enrols the phone against the server built here, for the // that enrols the phone against the server built here, for the
@@ -55,21 +46,4 @@ 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 (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
// both need.
Apk(
name: "shell",
modes: ["release", "debug"],
build: "cargo xtask apk",
),
], ],
+2 -22
View File
@@ -1,20 +1,6 @@
.gradle/
build/
app/androidApp/build/
app/shellApp/build/
local.properties
.kotlin/
*.iml
.idea/
.DS_Store
server/target/ server/target/
event-model/target/ event-model/target/
client-core/target/ app/target/
android-shell/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).
@@ -32,11 +18,5 @@ sessions/
# iris, the in-house UI library, is vendored at iris/ and built by cargo. # iris, the in-house UI library, is vendored at iris/ and built by cargo.
iris/target/ iris/target/
iris/android-app/target/
# E5's packaging xtask (RUST.md). `build/` above already covers scripts/rigs/gpu-probe/target/
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
# The repo root has no Cargo workspace, so this is xtask's own
# intermediate working files (target/xtask/apk/...), not a shared one.
xtask/target/
/target/
+4
View File
@@ -1,3 +1,7 @@
[submodule "wg-app-link"] [submodule "wg-app-link"]
path = wg-app-link path = wg-app-link
url = git@git.arirex.me:iris/wg-app-link.git url = git@git.arirex.me:iris/wg-app-link.git
[submodule "iris"]
path = iris
url = git@git.arirex.me:iris-ai/iris.git
branch = app-pin
+143 -526
View File
@@ -1,566 +1,183 @@
# ai-app # ai-app
A phone interface to AI coding sessions (Claude Code and llama.cpp), A phone and desktop interface to AI coding sessions. The backend is Rust/Axum;
replacing the Claude app for daily use. Rust/Axum backend on the desktop, the shared client and UI are Rust, drawn by the `iris` framework pinned as a
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token submodule. The
between them. Android app uses a thin Java activity and `android-view`; desktop uses winit.
**`docs/PLAN.md` is the design source of truth** — every decision with its `docs/PLAN.md` is the design source of truth. Read it before structural work
date, its rationale, and what was rejected. Read it before changing anything and update it when a decision changes. `docs/HANDOFF.md` is where the work in
structural, and update it in place when a decision changes rather than flight stands; read it first in a fresh session and keep it current. Working
letting this file and the plan become two versions of the truth. This file is documents are pruned as work lands: preserve current invariants, measurements,
the working notes layer: layout, commands, rigs, and things that have bitten. and failed hypotheses, not a chronicle of completed tasks. Do not create a
The design and working documents live under `docs/` — everything except this decisions log.
file and `CLAUDE.md`, which stay at the root because that is where Claude
Code and other agent harnesses look for them.
The central design point, worth not undoing by accident: **a session is a ## Architecture
child process, translated into one common event model.** A new session type
is a new driver — never a session-type branch in shared code (routes, A session is a child process translated by a driver into one common event
transcript, app screens). model. A new session type is a new driver, never a session-type branch in
shared routes, transcripts, or screens.
Android and desktop share `app/src/client` and `app/src/ui`. Platform modules
own only what the platform forces: JNI, lifecycle, insets and IME on one side;
winit and argv on the other. Layouts may differ, but widgets, styling, folding,
paging, config, and network logic are shared.
`iris/` is a UI framework and nothing else. It must not know about sessions,
transcripts, setups, or servers. Product code belongs in `app/`, and the
dependency runs one way.
## Layout ## Layout
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 + - `server/``ai-server`. `routes.rs`'s module comment is the HTTP table.
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single - `event-model/` — the wire contract shared by server and app.
`:androidApp` module), same cert scheme, same registry pattern. Read - `app/` — the `ai-app` crate. `client` is platform/UI independent; `ui`
dev-updater's `README.md` and `AGENTS.md` before diverging from them. contains Iris widget trees; `android` and `desktop` are thin hosts.
Module-by-module intent is in `docs/PLAN.md`'s "Backend layout". `android-project/` packages the Rust cdylib. The `bench` feature and
`bench-fixture/` are retained performance rigs, not a second app.
- `iris/` — the pinned framework submodule: proc macro, demos, and input rig.
- `scripts/` — repository-wide scripts and independent profiling rigs.
- `wg-app-link/` — a git submodule shared with dev-updater.
- `docs/` — design and working documents.
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc Clone with `--recurse-submodules` or run `git submodule update --init` to
comment is the HTTP table and the surface's source of truth. populate both `iris/` and `wg-app-link/`.
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
the Keystore-sealed token.
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
binding and the certificate's SANs (`netif`), owner-only files (`private`),
and the RON house rules (`format`). Clone with `--recurse-submodules`, or
`git submodule update --init` in an existing checkout — `server/` will not
build without it, since it is a path dependency, which is what keeps the two
projects version-locked to the commit this repo pins. What deliberately did
**not** move is the API surface and the config *schema*: routes, drivers,
sessions and setups are what makes this project itself.
- `docs/` — every design and working document except this file and
`CLAUDE.md`:
- `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs`
and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
- `docs/TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent.
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
the opening and stream effects in `SessionScreen.kt`.
- `docs/TODO.md` — the working list.
- `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
branch of the `ai-app-2` clone): what has to be reproduced, the
framework decision, and the ordered experiments with their pass
conditions. Read it before touching anything under that branch.
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`,
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's
own public API log, working list, decisions log, layout/render design,
and texture-atlas design, and the client-core crate's design,
respectively.
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
`service: Managed(…)`, supervised by Dev Updater's own implementation
rather than a script kept here) and the APK, in parallel. It points at
`resources.ron`, which is *ours* rather than Dev Updater's — it names
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
offer them. Note what deleting the config directory takes with it: the CA
under `certs`, which is the one-way door. **Stop** on the server card stops
the server a phone reaches through the tunnel, so on that phone it stays
down until somebody starts it again; Dev Updater reaches it over its own
port and is unaffected, which is what makes the button safe to press and
easy to regret.
### Icons Nerd Font icons are an app-owned committed subset. `app/build-icon-font.sh`
produces `app/assets/fonts/nerd_icons.ttf`; its codepoints must match
`app/src/ui/icon.rs`. The app registers it with Iris at startup. Body and
monospace fonts come from the platform; Iris ships no font assets.
**Nerd Fonts glyphs from a committed subset**, not vector assets and not ## Checking work
ordinary Unicode. `NerdIcons.kt` declares each codepoint and
`app/build-icon-font.sh` subsets the font; the two lists have to agree,
because a codepoint in the Kotlin that the script did not subset is a glyph
that silently isn't there. Rerun the script and commit its output when adding
one — it needs network access. `md-cog` and `md-refresh` are deliberately the
same codepoints dev-updater uses and must not drift from it. The subset is
the **Mono** face, where every glyph is one em square, which is what makes
two icon buttons the same width without either being given one — and why
`GLYPH_SIZE` is smaller than it looks like it should be.
## Checking your work Commit each coherent, warning-clean slice and push it.
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from - Whole product: `./scripts/run-tests.sh`.
`server/`), plus `cargo clippy --all-targets` and `cargo fmt`. The build - Framework: `cd iris && cargo fmt --all --check && cargo clippy --all-targets
stays warning-clean and rustfmt-clean at the defaults — there is no -- -D warnings && cargo test`.
`rustfmt.toml` and there should not be one. - App: `cd app && cargo fmt --all --check && cargo clippy --all-targets --
- **App**: from `app/`, -D warnings && cargo test`.
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat - Android: `cd app && ./build-apk.sh debug --abi x86_64` for this machine's
:androidApp:compileDebugKotlin :androidApp:lintDebug emulator, or `./build-apk.sh release` for a phone. The script builds with
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the cargo-ndk, packages with Gradle, and verifies the APK. Never infer phone
syntax highlighter, the ANSI parser and the transcript cache — the app's frame times from a debug emulator build.
pure logic with no Android in it. Touching anything under `BenchFixture.kt`,
`BenchNetwork.kt`, `BenchRun.kt` or the `bench` build type also needs
`:androidApp:compileBenchKotlin :androidApp:lintBench` — a second build
type compiles separately and lint has caught real bugs debug alone never
would (see "Android Lint" below).
- **Android Lint is not optional and is not run by a build.** It found a
crash that had been shipping (`java.time` on a minSdk-24 app with
desugaring off) and later a permission check that silently dropped every
notification on Android 12 and below. Fully clean as of 2026-08-31; keep it
that way, and suppress with `tools:ignore` plus a written reason rather
than by lowering the bar.
- Then `./build-apk.sh` for the APK to install on a phone through Dev
Updater, or `./run-android.sh` to build, install and launch on the
emulator. **The phone gets the release build**, signed with a key the
script generates once under `~/.config/ai-app/release.jks` (never in the
repo); `./build-apk.sh debug` builds the other variant, and Dev Updater's
build modes call the script with exactly that word. Dev Updater lists every
variant under `build/outputs/apk`, so pick `release` there; a phone still
holding the debug build has to uninstall it first, since the two are signed
differently.
- The emulator scripts stay on the debug build. **Never read a frame time
from one as the app's** — a debuggable build runs Compose at a fraction of
release speed; the render report says which build it came from.
## Running it here `app/`, `iris/`, and `scripts/rigs/ui-profile/` use rolling nightly through
per-directory toolchain files. `server/` and `event-model/` use stable.
- Run the server for development with `--bind 127.0.0.1`. Without it the The release signing key lives at `~/.config/ai-app/release.jks`, never in the
server binds wg0, which exists here but is unreachable from the emulator checkout. `build-apk.sh` creates it once. Normal builds use application id
(it dials 10.0.2.2). First run prints the enrollment QR/URI with the token. `com.example.aiapp`; benchmark builds add `.bench` and are built explicitly:
`ai-server --enroll-link` mints one more device's link while the server
keeps running; the server adopts that token on its first use. It is what
Dev Updater's Enroll button runs.
- Point development at a scratch state directory rather than the real one:
`--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`.
- **The APK pins the CA of the machine that builds it**, read at build time
from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides). So the
server must have started once on that machine first — the build stops with
that instruction otherwise — and an APK built in this VM only works against
a server in this VM.
- Prefer exercising the server directly over going through the UI:
`curl --cacert ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`.
The CA is wherever `--certs` put it — by default under `$XDG_CONFIG_HOME`,
never in the checkout, so a relative `certs/ca.pem` finds nothing.
The emulator app reaches it at `https://10.0.2.2:8443`; enroll with
`adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`.
- **`ai-server --delay MS` holds every response back.** Over the tunnel a
phone's requests take tens to hundreds of milliseconds, and several faults
live entirely in what the app does *while* one is outstanding. On a
loopback server those windows close before anything can be observed, so the
bug looks like it is not there.
- **`RUST_LOG=ai_server=debug`** logs every transcript page with its `before`,
`after` and what came back, and logs each SSE subscriber's cursor and
whether it was continued or reset (`stream backlog:`). That is the only
place "how far had this phone fallen behind" is answerable — the app sees a
window arrive and cannot tell.
- **`./test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
network namespaces inside one machine and drives the server through it — a
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
involved. That is how to verify the wg0-only posture.
## The rigs ./build-apk.sh release --features "screens bench"
Each exists because something was invisible without it. ## Running the server
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md Use `--bind 127.0.0.1` for emulator development. Without it the server binds
and DECISIONS.md's 2026-09-05 entries), the phone benchmark gate Iris wg0, which the emulator cannot reach. Use scratch state:
asked for before porting continues: a deterministic, checked-in synthetic
transcript (`app/bench-fixture/generate.py`, never a real one) that both
this app and iris open with no server, so a frame-time comparison
measures the renderer rather than the data. `./build-apk.sh bench` builds
it — own application id (`com.example.aiapp.bench`) and label ("AI
Sessions bench") so it installs beside a real enrollment rather than
replacing it. Opening it goes straight to a session screen holding the
fixture (no enrollment, no permission prompts) with a "Run benchmark"
control beside "Copy" in session settings: it drives the same scroll loop
and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over
`ui-trace`, but in-process, since a real phone has no usable system
tracing and no agent can drive one (this-machine-android's skill).
`BenchFixture.kt`/`BenchNetwork.kt` fake the backend by installing a
`URLStreamHandlerFactory` that answers `TranscriptSource`/`EventStream`'s
requests from an in-memory copy of the fixture instead of opening a
socket — so the fold, the paging and `uniqueItems` under test are the
screen's real ones, never a shortcut built just for this. The report
gains a `bench:` section (process CPU time, peak RSS, battery current) on
every build, empty except when `BenchRun.kt` filled it in.
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
and data directory, holding eight invented Claude Code transcripts and a
`claude` that is two lines of shell. **That isolation is the point**: the
import screen lists whatever is in `~/.claude/projects`, which in this VM is
real agent transcripts, so exercising *delete* against the ordinary server
deletes somebody's conversation and exercising *import* starts a real
`--resume` on the owner's account.
Its port and root derive from the checkout's name, so two checkouts'
sandboxes cannot reach each other, and its token is generated once into
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
the enrolment flow appended — so the emulator app is enrolled **once** (the
start banner prints the command) and stays enrolled. It shares the real TLS
certificates, because the installed APK pins that CA.
Driving verbs, so none of this is re-derived per session:
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
`./ui-sandbox.sh send SID text|@file`, and
`./ui-sandbox.sh api /path [curl args]`.
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
enrolment already there — for when the fixture under test was expensive to
build; plain `start` wipes them, which is right for the list-screen
fixtures and wrong for that.
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
fake CLI slow to start. Both exist because operations that finish in
milliseconds have states on the way that nothing can observe, and an
unobservable state is one where broken and working look identical.
It also builds a fixture tree at the sandbox home's `~/files` for the
explorer, holding the states otherwise only reachable by finding a real
machine in one: an empty directory, a name with a tab and one with an
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
symlink to a directory and a broken one, a source file per language, and
the three sizes the limits were measured against (`edit-32k.rs`,
`edit-128k.rs`, `big-source.rs`). Point a session at it with
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
The explorer's 409 is produced by editing the file on the machine
(`printf … > file`) between pressing the pencil and pressing save.
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
echo driver is the right rig for most things and the wrong one for anything
whose cost scales with what was actually written: a real reply is longer,
is real markdown, and carries tool calls whose input and output are
kilobytes. Two faults were invisible until a real transcript was loaded — a
page of history landing mid-fling threw the reader back to the newest end,
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
`-b` takes the biggest conversation on the machine rather than the newest,
which is what a scrolling test wants; `--stop` takes it down.
It copies the transcript into `/tmp` and gives the server a `HOME` of its
own, so the import can only see the copy — importing spawns `claude
--resume`, and against the real file that is a second CLI writing to a
conversation somebody may still be in. **A transcript never goes in this
repository**: they hold whatever was said, read and written in that
session, and `~/repos` is shared with the host besides.
- **A fake CLI exercises the process lifecycle without a token.** Point a
`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:
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
`--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
`debug-transcript.sh` when it is *what the transcript draws*.
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
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
copy button produces, whose `on screen:` line names what the viewport was
holding. Compare two runs with the same gestures; the emulator's absolute
frame times transfer nothing, the report's accounting does. Run it either
side of any change under `Markdown*.kt`, `Transcript*.kt` or
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
that move first are the worst `record: one block`, the reparse mean while
streaming, and the draw phase's accounting line.
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
resets the report, sends FILE, waits for the transcript to stop growing,
and prints. Both of those are corrections to a first version that measured
nothing: a transcript parked further back never redraws while a reply
streams into it, and a session is idle at *both* ends of a turn, so polling
for idle answers before the turn has started.
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
framework, from `atrace` text output with no trace processor needed. It is
how the cost of a layout node per link was attributed to the framework
rather than guessed at.
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and
**`iris/android-app/run-bench.sh [--apk PATH]`** installs it on this
checkout's own emulator, taps "Run benchmark" by label, and prints the
report -- written so the P0 build/install/tap/read-report cycle stops
being retyped by hand each time (docs/RUST.md's P0 box).
### Driving the UI ai-server --bind 127.0.0.1 --config /tmp/ai-config.ron \
--data-dir /tmp/ai-sessions --port 8444
**No script that drives this app's UI presses a coordinate.** Every control The emulator reaches the host at `10.0.2.2`. `ai-server --enroll-link` mints
is found by the name it already carries for assistive technology — another device link while the server runs. `--delay MS` is important for UI
`ui-trace record --do "tap 'Session settings'"` — which resolves the label states that disappear too quickly on loopback. `RUST_LOG=ai_server=debug`
against the screen at the moment of the gesture and fails the whole run when logs transcript page bounds and SSE catch-up/reset decisions.
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
coordinate is a position measured once by hand, and anything that moves the
control makes the tap land on whatever now sits there — the bench then
reports a number that was never measured, which reads exactly like a result.
Both bench scripts pressed the render report at `tap 723 205` until that
button moved into the session settings dialog on 2026-09-03. The check that
none has crept back:
grep -n "tap [0-9]" app/*.sh Exercise the server directly when possible:
Swipes are still coordinates, deliberately: a gesture across a scrolling area curl --cacert ~/.config/ai-app/certs/ca.pem \
is a distance rather than a control. -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions
**Two traps in the emulator bench loop**, each of which cost a run. `./scripts/test-wg-tunnel.sh up|test|down` builds a real WireGuard tunnel
`adb shell pm clear` removes the enrolment and the notification permission between network namespaces and verifies pinned TLS against 10.66.0.1.
along with the saved anchors, so the next run measures a permission dialog —
re-enrol with the command `ui-sandbox.sh` prints, and
`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id,
so the only way two builds start a scroll from the same place is a *fresh
session for each*.
**The emulator is `~/repos/emulator-tools`' business, not this repo's.** ## Rigs
`emu up` creates and boots the AVD named after this checkout — whatever `emu
name` prints, never a name typed out here, since this file is the same in
every clone. `run-android.sh` is that plus a build and an install. The `adb`
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
script from `emulator-tools` runs `emu check` before `installDebug`,
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
to every attached device; when it refuses, say which device you mean at the
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
### Testing llama.cpp and ssh here - `app/ui-sandbox.sh` runs an isolated delayed server with invented
transcripts, a fake CLI, stable enrollment, and a file-explorer fixture.
Its HOME and data are disposable; never point import/delete tests at real
`~/.claude/projects`.
- A two-line fake CLI (`#!/bin/sh`, `cat > /dev/null`) exercises adoption,
stop, restart, and process lifetime without using an account or token.
- `app/run-bench.sh` installs a benchmark APK on this checkout's emulator,
taps its accessibility-labelled control, and prints the report.
- `cd app && cargo test` drives the real transcript screen without a window
through `iris::harness`; touch recordings live in `app/touch/`.
- `iris/scripts/run-headless.sh phone --phone --dir ../app --shot …` opens the same
screen at phone size. `--replay ../app/touch/flick-120hz.touch` replays a
recorded gesture.
- `scripts/rigs/ui-profile/tests/frame_profile.rs` measures CPU frame cost;
`arena_churn.rs` measures GPU-array upload. Run ignored profiling tests in
release mode or the numbers are meaningless.
The prebuilt CPU llama.cpp lives outside the repo at The checked-in benchmark transcript is synthetic. Never put a real transcript
`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its in this repository; it contains conversation text, tool input, and file data.
own directory on `LD_LIBRARY_PATH`, so start the server as
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
usable speed on 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
broken driver — `llama-cli` produces the same from the file directly, which
is how to tell the two apart in a hurry.
There is no second machine, so **ssh this VM to itself**: generate a The emulator is a GLES rig. Its Vulkan implementation is SwiftShader, while
throwaway key, append the public half to `~/.ssh/authorized_keys`, and GLES is host-accelerated through virgl. Let Iris's runtime fallback select
configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus GLES; do not pass `force-gles`. Verify the `iris renderer:` log line before
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches interpreting a measurement. Vulkan is verified on desktop and a real phone.
nothing real. Point a provider's `command` at something harmless like
`/bin/echo` rather than at `claude`: the transport is what is under test, the
process exiting immediately is the signal, and it costs no tokens. **Take the
key back out afterwards.** The remote login shell here is **fish**; the
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
both, but that is luck rather than design, and a shell that is neither is the
thing to suspect first if a remote spawn ever mangles an argument.
## Where things run (host vs this VM) ## Driving Android UI
The machine itself — the two boxes, the shared `~/repos` mount, and why the Read the installed `this-machine-android` skill before using Gradle, adb, an
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that AVD, screenshots, or UI traces. This checkout gets its own AVD; resolve it
means here: with `emu serial` rather than typing a device name.
- **`ai-server` belongs on the host in production.** That is where the LAN Scripts tap controls by accessibility label, never by coordinate. Coordinates
address the phone can reach is, and where WireGuard terminates. are allowed for swipes because a swipe describes a distance across a scrolling
`wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it surface. A coordinate tap can silently hit a different control and turn a
there with `sudo WG_ENDPOINT=<ddns name>`. failed run into a plausible-looking result.
- **The tunnel and the real phone can never terminate in the VM**, because
nothing outside can open a connection into it. Phone bring-up is host work.
- `wg0` (10.66.0.1) exists in this VM too, so the production path is
exercisable during development. It has no reachable peer and does not need
one — but with no `--bind` the emulator cannot reach the server.
- **The `claude` CLI is only in the VM, so from the host it is a remote.**
The backend reaches it as it would any other machine.
- Starting the server in the VM makes a separate throwaway dev CA. **Never
install a build pinning that on the real phone.**
## Sessions outlive the backend ## Host and VM boundary
Since 2026-08-29 a session's process is deliberately left running when Production `ai-server` runs on the host, where the phone can reach WireGuard.
`ai-server` stops, and adopted again when it starts. docs/PLAN.md has the design; The Claude CLI is in this VM, so the host reaches it as a remote provider.
day to day: The VM's wg0 is useful for development but has no reachable phone peer. A dev
server in the VM creates a throwaway CA; never install an APK enrolled against
that CA on the real phone.
- **Stopping the server no longer stops the sessions.** After `pkill For llama.cpp tests, the CPU build is at `~/.local/opt/llama.cpp`; add that
ai-server` the `claude` processes are still there, on purpose directory to `LD_LIBRARY_PATH`. Avoid 2-bit quants for driver diagnosis because
(`reattaching to the claude-cli it left running` in the log). To end one, their fluent nonsense resembles a broken integration.
`POST /sessions/{id}/stop` — which keeps the session and its transcript,
and `/start` brings the process back on the same conversation — or delete
the session, which ends the conversation too.
- **A message or a command sent to a stopped session starts it**, so the
Start button is for when you want a process and nothing to say to it yet.
- **A backend start adopts and starts nothing.** If you are looking for a
stopped session's process after a restart, there is deliberately none.
- **A session spawned while testing cleans itself up**: `--throwaway-sessions`,
which a debug build defaults to on. Pass `--throwaway-sessions=false` to
keep what a development server spawns. The flag decides only what **new**
sessions are marked as; what happens on the way out is decided by the
**mark**.
- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and
`stderr.log`. `stdout.log` is the driver's input, read from the byte offset
in `process.json`; removing either by hand while the session is live loses
output or replays it.
## Importing For SSH transport tests, SSH this VM to itself with a throwaway key and a
harmless command. Remove the key afterwards. The remote login shell is fish,
so POSIX-quoting assumptions require explicit verification.
The import list reports each session's **size as well as its line count**, ## Session invariants
because the two disagree in the way that matters: these transcripts embed
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
count tells you what continuing a session will cost. Shown, not warned about;
importing a large session is a choice somebody is entitled to make.
**Never import a Claude Code session that is open in a terminal.** The app Sessions deliberately outlive `ai-server`. Shutdown leaves marked processes
refuses it — see docs/PLAN.md for the incident that made that a refusal rather running; restart adopts their process records without starting stopped
than a warning. sessions. Sending a message to a stopped session starts it. Use
`--throwaway-sessions` for test-created sessions.
**One Claude Code session id can name two files, and the listing offers it Each session directory contains `process.json`, `stdin.fifo`, `stdout.log`,
once.** Resuming from a different working directory makes the CLI write a and `stderr.log`. Do not edit or remove them while live: the stdout byte offset
second transcript with the same id under that directory's project folder — an in `process.json` prevents replay and loss.
ordinary state of a machine, not corruption. Everything downstream addresses
a session by id, and the phone keyed its list on it, so two rows sharing one
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
the copy with the most lines, because the other is usually a few-hundred-byte
stub and is often the *newer* of the two, so recency is the wrong key.
Deleting removes every copy rather than the first, or the row came back after
a delete that reported success. The phone's half is `uniqueItems`, which
every list keyed on a server-chosen id goes through: a repeat there must
never be able to close the app, whatever produced it.
**Deleting a session offers to take the machine's own transcript with it** — Never import a Claude Code session open in a terminal. One Claude session id
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the may occur in multiple project directories; import listing deduplicates by id
confirmation, and only where the driver keeps a record of its own and prefers the copy with more lines, while deletion removes every copy.
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
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,
since the sentence promising the conversation "should still be there to
import again" is exactly the one the switch makes false. The server deletes
the machine's copy *first*, so a machine it cannot reach leaves the session
where it was instead of half-deleted.
## Shared appearance Deleting an app session only deletes the provider's transcript when
`deleteForeign=true`. The server deletes the foreign transcript first so an
unreachable machine cannot leave a half-deleted session.
- **A row something is happening to is dimmed, drained of colour, and says ## Known traps
which operation in a word** — `BusyItem`, used by both the session list and
the import list so the appearance is learned once. The word rather than a
bare spinner because "deleting" and "importing" differ in kind. It does
**not** make the row inert: the caller disables its own click handler while
it passes a label. An overlay consuming pointer events was tried and
swallowed the drag along with the tap, so a list could not be scrolled
while anything in it was busy.
## Things that have bitten - `tracing` caches callsite interest process-wide. Logging tests must install
their capturing subscriber before any tested callsite runs.
Project-specific only — a lesson that would bite any project on this machine - `serde_json` needs `float_roundtrip`: transcript pages and SSE must preserve
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead. identical timestamp bytes.
- Import lookup must use `import::find`, not list every transcript. Validate
- **tracing caches callsite interest process-wide.** A test that hits a ids before putting them in a glob.
`tracing::warn!` with no subscriber installed can poison the interest cache - Transcript sequence numbers increase, so page edges are found by bisection.
for a concurrent test that captures logs (flaky "nothing was logged" Do not replace indexed window reads with whole-transcript parsing.
failures). Keep every exercise of a logging code path under the one - A page's event count has no fixed relationship to visible rows because
capturing subscriber — that is why the auth middleware has a single deltas and tool calls fold together. History cushions are measured in
combined gating+logging test. viewports, not row counts.
- **The composer can get stuck floating above the bottom of the screen after - Android generic motion is separate from touch. Keep hover, wheel, and mouse
the keyboard closes, while a reply is streaming.** The composer's position button handling in `iris::android`; product UI consumes the same pointer
and the transcript's bottom padding are both driven by the raw, animated state on desktop and Android.
`WindowInsets.ime` value read inside a `graphicsLayer` block, to avoid
recomposing the whole screen every frame of the keyboard's animation. That
animation is carried by a `WindowInsetsAnimationCallback`, and a callback
interrupted mid-flight leaves whatever it was carrying frozen at its last
value with nothing left to correct it. A streaming reply invalidates the
view every frame, which is exactly the condition known to starve that
callback of its `onEnd`. `WindowInsets.isImeVisible` does not share the
failure mode — it is set once, from the platform's own start/end of the
transition over a different path — so it is read once per keyboard toggle
and used to force both places back to zero.
**The guard is a boolean; the inset itself must never be read in the
composable body.** That correction first shipped as a `padding(bottom = …
imeInsets.getBottom(this) …)`, which subscribes the whole screen to a value
that changes every frame: measured at **16 full recompositions of
`SessionScreen` per keyboard open, against 1**. It is
`.then(if (imeVisible) Modifier.imePadding() else Modifier)` instead —
`imePadding` reads the inset in the layout phase, and dropping the modifier
is the same coercion to zero the boolean was added for. The counter to
check is `session screen recomposed` in the debug report, which should move
by one across a keyboard open, not by the number of frames it took.
- **The keyboard pans the window unless the activity opts into resize.**
Without `android:windowSoftInputMode="adjustResize"`, opening the IME
slides the whole window up (top bar off screen) instead of resizing —
`imePadding()` alone does not fix it and the transcript looks empty.
- **A PEM constant must start at the opening quotes.** A generated
`"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory` its
preamble sniff, so it tries DER instead and fails at runtime with
`ASN.1 … DECODE_ERROR` — nowhere near the code that produced it.
- **ZXing only looks for a dark code on a light ground.** The enrollment QR
is block characters in the terminal's foreground colour, so a dark-themed
terminal renders it as a negative and the in-app scanner silently never
matches — while the phone's own camera app, which tries both, does. The
scanner asks for `Intents.Scan.MIXED_SCAN`, which alternates normal and
inverted frames; keep it that way rather than making the server dictate the
colours.
- **`serde_json`'s default float parser is not correctly rounded**, so the
server handed out the same transcript line two different ways: a `ts` of
`1788546972.6030757` came back from `/transcript` as `…0755` while the SSE
stream sent the original. Nothing on screen could show it — a `ts` is drawn
as a relative time — and what found it was the phone's cache comparing a
line it held against the server's answer. The `float_roundtrip` feature in
`server/Cargo.toml` is the fix and
`a_line_read_back_is_the_line_that_was_written` is what keeps it; that test
fails within a second of the feature being dropped.
- **Resolving one importable session used to list every one of them.**
`import::delete` and the import seed both called `list`, which reads every
transcript Claude Code has ever written — measured at 3.7 seconds against
the 867 MB in this VM, paid once per session in a batch. `import::find`
takes the same script with one glob narrower: 78ms. Ids are checked
(`is_session_id`) before they reach that glob, since a `/` or `..` walks it
out of the projects directory.
- **A transcript page used to cost the whole transcript.** `read_window` read
and parsed every line and then kept the last `limit` of them, so the work
was the size of the conversation rather than the size of the answer: one
page of a 21 MB, 24,000-event transcript took ~500ms to return 620 KB, and
took the same 500ms whichever page was asked for. It is a bisection now
(`Indexed` in `transcript.rs`) — sequence numbers only increase, so the
edge of a range is found by parsing one line per halving. Same page,
~110ms, of which ~20ms is the file scan. The file is still read whole; that
is where the remaining cost is, and going further means a chunked backwards
reader.
- **Paging back has two failures that look like "there is simply no more
history", and neither says anything on screen.** Both invisible on a
loopback server and reproducible at `--delay 150`. The pager fires on the
*first layout*, before any event has arrived — `moreHistory` starts true,
so the spinner is in the list and `visibleItemsInfo` is not empty — and
`before = 0` asks for the events before the first one, which is none, which
is exactly how this code is told it has reached the start. `loadOlderPage`
refuses `oldestSeq == 0` now. And `joinPages` only ran `adoptRun` on the
path where a *split* call had been found, so a boundary landing cleanly
between two calls — most of them — left one run of tool calls drawn as two
groups with the seam wherever the reader happened to have paged.
Reproducing either takes a boundary placed on purpose: the opening page is
80 events, so arrange the transcript so that event counts back from the
newest.
- **A page is 800 events and a screen is a handful of rows, and the two have
no fixed ratio.** A run of thirty-five tool calls is one row; a reply is
hundreds of text deltas folded into one. So anything that budgets in rows
has to measure a screen rather than name a number: the history cushion was
eight rows, which on a tool-heavy transcript is less than one screenful, so
the reader hit the end of what was loaded on every swipe and stood there
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
is actually on screen.
- **Only `fetchTranscript` was off the main thread; the fold was not.**
`foldEvent` returns a new list per event, so a page is that many copies of
a growing list — fine at 80 events and about 300,000 element copies at 800,
run in the middle of the scroll that asked for it. `warm` had the same
shape: the `markdownIn` scan that decides *what* to parse ran before the
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
that wraps the *fetch* and leaves the work done with the result outside it.
## Measurements worth not re-taking
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
emulator against a real imported transcript with the server at
`--delay 120`. Settled and flinging fast, both into fresh history and back
through rows already drawn: **5.25.9% janky frames, 99th percentile
2932ms, 02 slow UI-thread frames.** The stock Settings app on the same
device is 3.3% and 38ms, so this is at the platform floor. The number that
is *not* at the floor is the first few seconds after opening a session,
where every row on the way is being composed for the first time; that is
inherent to a lazy list and it is why a measurement taken before the screen
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
- **The reset path is not reachable by reopening a session.** Measured
2026-09-04 against a session streaming at 20 events a second: reopening one
with an anchor 1,800 events back connects **87119 events behind**, well
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
opening page, then one span covering the whole distance. To exercise the
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
the app takes the reset on a live connection, clears, refills and carries
on without reconnecting.
- **The session screen's stream survives backgrounding here** — 20 seconds at
the launcher while 415 events were produced brought no reconnect at all,
which is not what the comment above that loop expects, and is most likely
this emulator being headless rather than the phone's behaviour.
- **Reopening a cached session costs one request for one event** (the probe),
and scrolling the whole conversation back costs nothing more; a cold open
of the same 500-event session is two pages, 100 events. Measured
2026-09-04 on the emulator against the sandbox.
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
28,000-line file because it draws one row per line; the editor is one
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
screen. If you make the editor faster, that number is what to move.
docs/EXPLORER.md's "What the measurements said" has the rest.
-44
View File
@@ -1,44 +0,0 @@
# Decisions awaiting review
Choices made while working autonomously, for Bryan to keep or change. Each
says what was picked and why; the detail is in the design doc it names.
Delete an entry once it has been looked at.
## Subagent views (2026-09-05, `SUBAGENTS.md`)
Made on my own judgement, limited blast radius:
1. **A subagent is a transcript, not a session.** It has no process,
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
and stored under the session's directory, so deleting the session takes
it. Alternative rejected: registering it as a session of its own, which
would give it a card in the main list and a driver that can do nothing.
2. **Read-only view is the session screen minus its controls**, rather than
a second, simpler transcript screen. Keeps paging, caching, selection
and rendering in one place. Cost: a `readOnly` mode threaded through
`SessionScreen`.
3. **The list only carries a count.** Each session row says how many
subagents it has; their titles and statuses are fetched when the card is
expanded. Keeps `GET /sessions` from reading every subagent transcript.
Consequence: an expanded card's statuses refresh with the list, not live.
4. **Expanded/collapsed is remembered per session on the phone**, not on
the server. Collapsed by default, per the transcript convention that new
things arrive collapsed.
5. **Subagents of imported sessions are not shown.** The import path still
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
are not read. Only subagents run while this backend was watching exist.
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
paid turn to exercise.
Deferred, because they reach further than this feature:
- **Live status on the list.** Whether the session list should follow a
stream at all (it refreshes on demand today) decides whether subagent
status can ever be live there. Not changed.
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
in its transcript and are not given transcripts of their own. Supporting
that is the same mechanism one level down, but the UI would need nested
expanders.
- **The subagent status row says "context unknown".** Nothing measures a
subagent's context; the row could leave it out rather than admit it.
-1081
View File
File diff suppressed because it is too large. Load diff
-36
View File
@@ -1,36 +0,0 @@
[package]
name = "android-shell"
version = "0.1.0"
edition = "2024"
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
# why those two classes cannot be anything but Java/Kotlin, registered from
# the manifest by name). Everything they would otherwise have done in
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
# picking a session for a share -- is here instead, built on `client-core`
# so the networking and parsing are not duplicated a third time next to the
# server and the Kotlin app.
#
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
# on a normal host target without an Android NDK toolchain, the same
# posture `client-core` and `server` already have.
[lib]
name = "android_shell"
crate-type = ["cdylib", "lib"]
[dependencies]
client-core = { path = "../client-core" }
jni = "0.22"
log = "0.4"
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
# without a backend installed -- so without this, every recoverable error
# at a native entry point would be silently dropped rather than reaching
# logcat. Android-only: nothing else here needs it, and it does not build
# off-device (see `notify::ensure_logger`'s call site, the only place this
# is used).
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.15"
-152
View File
@@ -1,152 +0,0 @@
//! Thin wrappers around the five `Env` calls this crate makes constantly
//! (a class name, a method name and a signature, all as plain `&str`).
//!
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
//! here uniformly rather than switching to the compile-time `jni_str!`
//! literal macro call by call -- these are a handful of short, one-off
//! lookups, not a hot loop, so the difference is not worth two code paths
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
//! `FieldSignature`, which is why those go through
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
//! parsed form is what lets these calls skip re-validating the signature
//! against the arguments on every call, which is the whole reason `jni`
//! moved to it.
//!
//! **The classloader gotcha, found by testing (2026-09-05).** A class
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
//! `get_static_field` -- anything that resolves a *class*, as opposed to
//! `call_method` on an object it already has, which needs no such lookup)
//! defaults to `FindClass`'s ordinary search when it cannot find the
//! calling thread a classloader through `Thread.getContextClassLoader()`.
//! That default is fine on a thread the JVM itself started -- an
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
//! from `android-shell`'s own background thread (the notification
//! follow-loop, the share upload) is running on a thread *Rust* spawned
//! and attached with `JavaVM::attach_current_thread`, which the platform
//! never gave an app classloader. Framework classes
//! (`android.app.Notification$Builder`, ...) still resolve, because they
//! are reachable from the bootstrap loader `FindClass` falls back to --
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
//! packaged inside this app's own APK. The failure was
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
//! as "failed to resolve Java class ... (class not found or linkage
//! error)" -- on a real device this reads as "the notification silently
//! never arrives," since the whole call is inside the follow loop and the
//! ongoing foreground notification (built on the main thread, in
//! `try_start`, before the background thread exists) posts fine either
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
//! first time any entry point has a `Context` to ask, and every class
//! lookup below goes through it explicitly via `LoaderContext::Loader`
//! rather than the thread-dependent default -- so it is correct on the
//! main thread and on this crate's own background threads alike.
use jni::Env;
use jni::errors::Result;
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
use jni::refs::{Global, LoaderContext};
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
use jni::strings::JNIString;
use std::sync::OnceLock;
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
/// Caches `context`'s own `ClassLoader`, the first time this is called.
/// Cheap to call from every entry point that has a `Context` on hand
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
/// a `OnceLock::get` and nothing else.
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
if CLASS_LOADER.get().is_some() {
return Ok(());
}
// context.getClass().getClassLoader() -- resolved via `call_method` on
// real objects throughout, so this needs no class-name lookup of its
// own and has nothing to bootstrap.
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
let loader_obj = call_method(
env,
&class_obj,
"getClassLoader",
"()Ljava/lang/ClassLoader;",
&[],
)?
.l()?;
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
let global = env.new_global_ref(&loader)?;
// Lost the race with another entry point calling this concurrently --
// both loaders name the same app, so either one is fine and there is
// nothing to reconcile.
let _ = CLASS_LOADER.set(global);
Ok(())
}
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
/// through the cached app classloader when one has been remembered, and
/// through the ordinary default otherwise -- which is every call made
/// before any entry point has run, and is also correct for a main-thread
/// caller, so there is no case this makes worse.
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
match CLASS_LOADER.get() {
Some(loader) => {
let binary_name = name.replace('/', ".");
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
}
None => env.find_class(JNIString::new(name)),
}
}
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
resolve_class(env, name)
}
/// A new Java string as a plain `JObject` -- what every call site here
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
/// `JString: Into<JObject>` is the documented way across).
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
Ok(env.new_string(text)?.into())
}
pub fn new_object<'local>(
env: &mut Env<'local>,
class: &str,
sig: &str,
args: &[JValue],
) -> Result<JObject<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.new_object(class, sig.method_signature(), args)
}
pub fn call_method<'local>(
env: &mut Env<'local>,
obj: &JObject,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
}
pub fn call_static_method<'local>(
env: &mut Env<'local>,
class: &str,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
}
pub fn get_static_field<'local>(
env: &mut Env<'local>,
class: &str,
field: &str,
sig: &str,
) -> Result<JValueOwned<'local>> {
let sig = RuntimeFieldSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.get_static_field(class, JNIString::new(field), sig.field_signature())
}
-131
View File
@@ -1,131 +0,0 @@
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
//! package comment for what this crate is and RUST.md's E3 entry for the
//! design decisions.
//!
//! Each native method is declared with `jni`'s [`native_method!`] macro
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
//! the macro derives the mangled export name and the JNI signature from the
//! Rust function itself, so the two cannot drift apart the way a
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
//! routinely do. `error_policy = LogErrorAndDefault` matches
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
//! JNI call that threw) is reported to logcat, not thrown back into Java
//! as an exception that would crash the app over something recoverable.
//!
//! Each `const _: NativeMethod = native_method! { ... };` binding is
//! otherwise unused by name -- `_` is the idiomatic way to keep a
//! side-effecting const (here, generating the `#[export_name]`d function
//! the JVM resolves by the JNI naming convention) without a `dead_code`
//! warning for a binding nothing reads.
mod jcall;
mod notify;
mod settings;
mod share;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JClass, JObject};
use jni::sys::jint;
use jni::{Env, NativeMethod, native_method};
/// Installs the `log` backend that routes to logcat, once per process.
/// Without it, `LogErrorAndDefault` (every native method below) and any
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
/// call into the `log` facade's default no-op logger, and a real failure
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
/// crashing, since nothing on screen or in the log says a notification was
/// dropped. Called from every entry point below rather than a Java-side
/// `Application.onCreate`, since this crate deliberately has no such class
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
fn ensure_logger() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
#[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,
};
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
fn native_handle_intent<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
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,
};
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
/// enrollment may have just landed) and from `NotificationService.sync`
/// itself. See `notify::sync`.
fn native_sync<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
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,
};
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
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,
};
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
fn native_on_destroy<'local>(
_env: &mut Env<'local>,
_class: JClass<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
notify::on_destroy();
Ok(())
}
-556
View File
@@ -1,556 +0,0 @@
//! 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 client_core::api::UreqTransport;
use client_core::notifications::{SessionNotification, follow_notifications};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JValue};
use jni::sys::{JNI_TRUE, jint};
use crate::settings::{self, ServerSettings};
const ALERT_CHANNEL: &str = "sessions";
const ONGOING_CHANNEL: &str = "connection";
const ONGOING_ID: i32 = 1;
const ALERT_ID: i32 = 2;
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
/// Whether the follow-loop thread is already running. **A deviation from
/// `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);
/// Set by `nativeOnDestroy`, checked by the follow loop between
/// reconnects. **Known gap, recorded rather than hidden**: unlike
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
/// can interrupt a `ureq` read already blocked inside one connection --
/// `Transport::stream` hands back a plain `Read` with no cancellation
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
/// practice this is a bounded wait rather than a hang; closing that gap
/// for real means adding a cancellation point to `client_core::Transport`,
/// which is a decision affecting every caller of that trait, not just this
/// one -- left for whoever next depends on prompt shutdown.
static STOPPING: AtomicBool = AtomicBool::new(false);
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
crate::jcall::get_static_field(env, class, field, "I")?.i()
}
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
crate::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::jcall::jstr_obj(env, id)?;
let builder = crate::jcall::new_object(
env,
"androidx/core/app/NotificationChannelCompat$Builder",
"(Ljava/lang/String;I)V",
&[JValue::Object(&id_j), JValue::Int(importance)],
)?;
let name_j = crate::jcall::jstr_obj(env, name)?;
crate::jcall::call_method(
env,
&builder,
"setName",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
&[JValue::Object(&name_j)],
)?;
let channel = crate::jcall::call_method(
env,
&builder,
"build",
"()Landroidx/core/app/NotificationChannelCompat;",
&[],
)?
.l()?;
crate::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::jcall::find_class(env, class_name)?;
crate::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::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
crate::jcall::call_method(
env,
&intent,
"setAction",
"(Ljava/lang/String;)Landroid/content/Intent;",
&[JValue::Object(&action_view)],
)?;
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
crate::jcall::call_method(
env,
&builder,
"scheme",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&scheme)],
)?;
let authority = crate::jcall::jstr_obj(env, "session")?;
crate::jcall::call_method(
env,
&builder,
"authority",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&authority)],
)?;
let path = crate::jcall::jstr_obj(env, session_id)?;
crate::jcall::call_method(
env,
&builder,
"appendPath",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&path)],
)?;
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
crate::jcall::call_method(
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::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::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::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
let builder = crate::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
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::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::jcall::call_method(
env,
context,
"stopService",
"(Landroid/content/Intent;)Z",
&[JValue::Object(&service_intent)],
)?;
return Ok(());
}
create_channels(env, context)?;
crate::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(())
}
/// The `Service.onStartCommand` body -- loads settings, starts the
/// foreground notification, and spawns the follow-loop thread. Answers the
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
/// framework rather than hardcoded so a wrong guess at their values cannot
/// silently pick the other behaviour.
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
match try_start(env, &service) {
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
Ok(false) => {
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
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::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 || {
// Requests a *permanent* attachment (detached only when this thread
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
// this is the long-lived follow loop, not a one-shot callback.
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
follow_loop(env, &context, settings, &ca);
Ok(())
});
})
.ok();
Ok(true)
}
/// Follows the backend's notification stream, reconnecting until stopped
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
/// ordinary case, so it retries quietly and forever; nothing is shown when
/// it cannot connect, for the same reason as the Kotlin original: a
/// notification saying "I could not tell you whether anything happened" is
/// noise about a condition nobody can act on.
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
while !STOPPING.load(Ordering::SeqCst) {
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
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);
}
}
/// One notification per session, replacing that session's previous one --
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
/// branches this module's doc comment explains.
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
let manager = notification_manager(env, context)?;
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
let allowed = if sdk < tiramisu {
true
} else {
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
let granted = static_int(
env,
"android/content/pm/PackageManager",
"PERMISSION_GRANTED",
)?;
let result = crate::jcall::call_static_method(
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::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
if !allowed || !enabled {
return Ok(());
}
let intent = session_intent(env, context, &notification.session_id)?;
let pending = pending_activity(env, context, &intent)?;
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
let builder = crate::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::jcall::jstr_obj(env, &notification.title)?;
builder_call(
env,
&builder,
"setContentTitle",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let text = crate::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::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
.l()?;
let tag = crate::jcall::jstr_obj(env, &notification.session_id)?;
crate::jcall::call_method(
env,
&manager,
"notify",
"(Ljava/lang/String;ILandroid/app/Notification;)V",
&[
JValue::Object(&tag),
JValue::Int(ALERT_ID),
JValue::Object(&built),
],
)?;
Ok(())
}
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
/// the gap this module's `STOPPING` doc explains.
pub fn on_destroy() {
STOPPING.store(true, Ordering::SeqCst);
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
// 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::jcall::jstr_obj(env, "android-shell")?;
let msg = crate::jcall::jstr_obj(env, &message)?;
crate::jcall::call_static_method(
env,
"android/util/Log",
"e",
"(Ljava/lang/String;Ljava/lang/String;)I",
&[JValue::Object(&tag), JValue::Object(&msg)],
)?;
Ok(())
})();
}
-141
View File
@@ -1,141 +0,0 @@
//! 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)
}
}
/// This experiment's own scheme and Keystore alias -- distinct from the
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
/// installed side by side on the same development device without
/// colliding over which one a scanned QR or a deep link resolves to. See
/// RUST.md's E3 entry for why they are not the same value.
pub(crate) const SCHEME: &str = "aiappshell";
const KEY_ALIAS: &str = "aiapp-shell-token-key";
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
crate::jcall::new_object(
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::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
let token = get_string(env, settings_obj, "getToken")?;
Ok(ServerSettings { host, port, token })
}
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
let 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::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)?))
}
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
let store = new_store(env)?;
let host = crate::jcall::jstr_obj(env, &settings.host)?;
let token = crate::jcall::jstr_obj(env, &settings.token)?;
let settings_obj = crate::jcall::new_object(
env,
SETTINGS_CLASS,
"(Ljava/lang/String;ILjava/lang/String;)V",
&[
JValue::Object(&host),
JValue::Int(settings.port),
JValue::Object(&token),
],
)?;
crate::jcall::call_method(
env,
&store,
"save",
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
&[JValue::Object(context), JValue::Object(&settings_obj)],
)?;
Ok(())
}
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
/// query-parameter rules rather than re-deriving them here.
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::jcall::call_method(
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::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())
}
-183
View File
@@ -1,183 +0,0 @@
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
//!
//! **Scope cut, recorded rather than silent**: only shared *text*
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
//! ported here, because `client-core`'s `ApiClient` does not have the
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
//! `content://` stream and no text falls through to a toast saying so,
//! rather than silently doing nothing. Closing this gap is the same
//! `client-core` work whichever caller needs it next.
//!
//! **Which session a share lands in** is also a placeholder: with no
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
//! to whichever session has the latest `last_activity` -- the one most
//! likely to be what somebody meant. Worth revisiting once a real screen
//! exists to ask instead of guessing.
use client_core::api::{ApiClient, UreqTransport};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
use crate::notify;
use crate::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::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::jcall::jstr_obj(env, message)?;
crate::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(())
}
/// The one place an incoming intent is sorted into what it means -- mirrors
/// `MainActivity.kt`'s `handleIntent`.
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let action = get_string_method(env, intent, "getAction")?;
if matches!(
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::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(());
};
// There is no session screen yet (E4's job); the toast is this
// experiment's stand-in proof that the tap was routed to the right
// session id.
toast(env, activity, &format!("Opened session {session_id}"))
}
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"),
}
}
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
/// what happens to it, minus attachments per this module's doc comment.
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
let text = crate::jcall::call_method(
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)
}
+7
View File
@@ -0,0 +1,7 @@
android-project/.gradle/
android-project/build/
android-project/app/build/
# Rebuilt by build-apk.sh before every Gradle build.
android-project/app/src/main/jniLibs/
target/
Cargo.lock.orig
+768 -599
View File
File diff suppressed because it is too large. Load diff
+105
View File
@@ -0,0 +1,105 @@
# 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"] }
# UI dependencies stay optional so client-only tests do not link the renderer.
iris = { path = "../iris", 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"]
bench = ["screens", "fixture", "dep:libc", "dep:tokio"]
force-gles = ["screens", "iris/force-gles"]
[dev-dependencies]
tempfile = "3"
tokio = { version = "1.53.1", features = ["rt", "time"] }
swash = "0.2.10"
# 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"]
-55
View File
@@ -1,55 +0,0 @@
#!/bin/sh
# Android SDK environment for this app's Gradle build: locates the SDK and
# exports the PATH/env vars the build needs. Pure Kotlin/Gradle, so nothing
# Rust/NDK-specific belongs here.
#
# Source this directly for one-off commands instead of going through the
# full run-android.sh (which also creates/boots the emulator, builds,
# installs, and launches):
#
# . ./android-env.sh
# ./gradlew :androidApp:assembleDebug
# adb devices
#
# Safe to source repeatedly. Intentionally does NOT `set -e`/`set -u`: this
# file is meant to be sourced into whatever shell is already running --
# including a long-lived one a session reuses for unrelated commands -- and
# changing that shell's error-handling options as a side effect of sourcing
# would be surprising. run-android.sh, which does want strict mode, sets its
# own `set -eu` before sourcing this.
# Hardcoded (not derived from an inherited ANDROID_HOME) so this doesn't
# silently follow whatever that happens to be set to elsewhere -- e.g. this
# sandbox's own profile exports ANDROID_HOME=/opt/android-sdk system-wide, a
# root-owned install this user can't write to. Everything needed lives under
# the path below instead, matching Android Studio's own default SDK location
# convention on Linux.
SDK_ROOT="$HOME/Android/Sdk"
ANDROID_HOME="$SDK_ROOT"
ANDROID_SDK_ROOT="$SDK_ROOT"
# ~/.local/bin is where the `android` CLI itself installs to (see its own
# installer); adding it here too means sourcing this script guarantees a
# working `android` command even in a shell that hasn't picked up
# ~/.profile yet.
PATH="$HOME/.local/bin:$SDK_ROOT/cmdline-tools/latest/bin:$SDK_ROOT/platform-tools:$SDK_ROOT/emulator:$PATH"
# Pin the AVD directory explicitly so avdmanager (creation) and the emulator
# binary (lookup at start time) are guaranteed to agree on where the AVD
# lives -- left to their own defaults they can resolve different locations
# and disagree on whether it exists.
ANDROID_AVD_HOME="${ANDROID_AVD_HOME:-$HOME/.android/avd}"
mkdir -p "$ANDROID_AVD_HOME"
export ANDROID_HOME ANDROID_SDK_ROOT ANDROID_AVD_HOME PATH
echo "==> Ensuring required SDK packages are installed in $SDK_ROOT"
# $SDK_ROOT is user-owned (unlike /opt/android-sdk), so this genuinely
# installs anything missing rather than just probing for it -- still
# best-effort (`|| echo`) so a transient network hiccup doesn't abort a
# script sourcing this under `set -e`.
#
# build-tools is needed twice over: by Gradle for this app's own build, and
# by ../server at runtime for `aapt2` (reading a discovered APK's package
# name) and `llvm-strip`/`apksigner` (the slim-APK pipeline).
android sdk install "cmdline-tools/latest" "platform-tools" "emulator" \
"platforms/android-37.0" "build-tools/37.0.0" \
"system-images/android-36/google_apis/x86_64" \
|| echo " (non-fatal: see above)"
+63
View File
@@ -0,0 +1,63 @@
plugins {
id("com.android.application")
}
// build-apk.sh places the Rust cdylib in src/main/jniLibs before Gradle runs.
def benchBuild = System.getenv("AI_APP_BENCH") == "1"
android {
namespace = "dev.iris.android.demo"
compileSdk = 37
defaultConfig {
applicationId = "com.example.aiapp"
// 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
// targetSdk 35+ supplies real IME overlap under enforced edge-to-edge.
targetSdk = 37
versionCode = 1
versionName = "1.0"
manifestPlaceholders = [appLabel: benchBuild ? "AI Sessions bench" : "AI Sessions"]
}
// The signing key is machine-local; build-apk.sh creates and supplies it.
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 {
if (benchBuild) {
applicationIdSuffix ".bench"
}
}
release {
if (benchBuild) {
applicationIdSuffix ".bench"
}
if (keystore != null) {
signingConfig = signingConfigs.release
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The transcript client talks to ai-server. -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="${appLabel}"
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>
<!-- Enrollment links minted by ai-server and Dev Updater. -->
<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>
<!-- Read-only recent logs for Dev Updater. The authority follows
applicationId so normal and benchmark builds stay separate. -->
<provider
android:name=".DevLogProvider"
android:authorities="${applicationId}.devlog"
android:exported="true"
android:readPermission="dev.updater.permission.READ_DEVLOG" />
</application>
</manifest>
@@ -0,0 +1,125 @@
package dev.iris.android.demo;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
/** Read-only Dev Updater log provider; its URI and column schema are an external contract. */
public final class DevLogProvider extends ContentProvider {
static {
// A provider can start the process without creating MainActivity.
System.loadLibrary("ai_app");
}
private static final int FIELDS_PER_LINE = 5;
private static final String[] LINE_COLUMNS = {"seq", "t_ms", "level", "target", "message"};
private static final String[] STATUS_COLUMNS = {"held", "dropped", "newest_seq"};
private static final int LINES = 1;
private static final int STATUS = 2;
private UriMatcher matcher;
private static native String[] nativeLinesSince(long since);
private static native String[] nativeStatus();
// The provider may be the process's only component, so it must supply
// the files directory normally initialized by MainActivity.
private static native void nativeReady(String authority, String filesDir);
@Override
public boolean onCreate() {
String authority = getContext().getPackageName() + ".devlog";
matcher = new UriMatcher(UriMatcher.NO_MATCH);
matcher.addURI(authority, "lines", LINES);
matcher.addURI(authority, "status", STATUS);
nativeReady(authority, getContext().getFilesDir().getAbsolutePath());
return true;
}
@Override
public Cursor query(
Uri uri,
String[] projection,
String selection,
String[] selectionArgs,
String sortOrder) {
switch (matcher.match(uri)) {
case LINES:
return lines(sinceOf(uri));
case STATUS:
return status();
default:
return null;
}
}
private static long sinceOf(Uri uri) {
String since = uri.getQueryParameter("since");
if (since == null) {
return 0;
}
try {
return Long.parseLong(since);
} catch (NumberFormatException ignored) {
return 0;
}
}
private static Cursor lines(long since) {
String[] fields = nativeLinesSince(since);
if (fields == null) {
return null;
}
MatrixCursor cursor = new MatrixCursor(LINE_COLUMNS, fields.length / FIELDS_PER_LINE);
for (int at = 0; at + FIELDS_PER_LINE <= fields.length; at += FIELDS_PER_LINE) {
cursor.addRow(
new Object[] {
Long.parseLong(fields[at]),
Long.parseLong(fields[at + 1]),
fields[at + 2],
fields[at + 3],
fields[at + 4],
});
}
return cursor;
}
private static Cursor status() {
String[] fields = nativeStatus();
if (fields == null || fields.length != STATUS_COLUMNS.length) {
return null;
}
MatrixCursor cursor = new MatrixCursor(STATUS_COLUMNS, 1);
cursor.addRow(
new Object[] {
Long.parseLong(fields[0]), Long.parseLong(fields[1]), Long.parseLong(fields[2]),
});
return cursor;
}
@Override
public String getType(Uri uri) {
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("this app's log is read-only");
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("this app's log is read-only");
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("this app's log is read-only");
}
}
@@ -0,0 +1,60 @@
package dev.iris.android.demo;
import android.app.Activity;
import android.content.Context;
import android.view.Gravity;
import android.widget.ScrollView;
import android.widget.TextView;
import org.linebender.android.rustview.RustView;
/**
* android-view's abstract base plus the two native methods it has no hook
* for: window insets and unregistering this view's entry in
* iris::android::insets's side table. See iris/src/android/insets.rs's doc
* comment for why those could not ride along on an existing android-view
* callback the way the back gesture does.
*/
public final class IrisView extends RustView {
@Override
protected native long newViewPeer(Context context);
native void applyWindowInsetsNative(
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
native void unregisterInsetsNative(long peer);
public IrisView(Context context) {
super(context);
}
void applyWindowInsets(
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
}
@Override
protected void onDetachedFromWindow() {
unregisterInsetsNative(mViewPeer);
super.onDetachedFromWindow();
}
// This path must not depend on the renderer that failed to initialize.
void showRendererError(String report) {
Context context = getContext();
if (!(context instanceof Activity)) {
return;
}
Activity activity = (Activity) context;
TextView text = new TextView(activity);
text.setText(report);
text.setTextIsSelectable(true);
text.setGravity(Gravity.TOP | Gravity.START);
int pad = (int) (16 * activity.getResources().getDisplayMetrics().density);
text.setPadding(pad, pad, pad, pad);
ScrollView scroll = new ScrollView(activity);
scroll.addView(text);
activity.setContentView(scroll);
}
}
@@ -0,0 +1,104 @@
package dev.iris.android.demo;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.WindowInsets;
import android.view.WindowInsetsAnimation;
import android.widget.FrameLayout;
import java.util.List;
public final class MainActivity extends Activity {
static {
System.loadLibrary("ai_app");
}
private static native void nativeSetFilesDir(String path);
private static native void nativeEnroll(String uri);
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
nativeSetFilesDir(getFilesDir().getAbsolutePath());
handleEnrollmentIntent(getIntent());
IrisView view = new IrisView(this);
view.setLayoutParams(new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
view.setFocusable(true);
view.setFocusableInTouchMode(true);
FrameLayout layout = new FrameLayout(this);
layout.addView(view);
setContentView(layout);
view.requestFocus();
// Edge-to-edge makes IME-only changes produce fresh inset dispatches.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getWindow().setDecorFitsSystemWindows(false);
}
// Static dispatch supplies settled insets; the animation callback
// supplies intermediate IME heights. An interrupted animation may
// omit its final progress frame, so onEnd re-reads the root insets.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
@Override
public WindowInsets onProgress(
WindowInsets insets, List<WindowInsetsAnimation> running) {
sendInsets(view, insets);
return insets;
}
@Override
public void onEnd(WindowInsetsAnimation animation) {
WindowInsets settled = view.getRootWindowInsets();
if (settled != null) {
sendInsets(view, settled);
}
}
});
}
view.setOnApplyWindowInsetsListener((v, insets) -> {
sendInsets((IrisView) v, insets);
return insets;
});
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
// Keep getIntent() consistent with the enrollment being handled.
setIntent(intent);
handleEnrollmentIntent(intent);
}
private static void handleEnrollmentIntent(Intent intent) {
if (intent == null) {
return;
}
Uri data = intent.getData();
if (data != null) {
nativeEnroll(data.toString());
}
}
private static void sendInsets(IrisView view, WindowInsets insets) {
int left = insets.getSystemWindowInsetLeft();
int top = insets.getSystemWindowInsetTop();
int right = insets.getSystemWindowInsetRight();
int bottom = insets.getSystemWindowInsetBottom();
// Visibility and height disagree during IME animation, so neither
// can be inferred from the other.
int imeBottom = 0;
int imeVisible = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
}
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
}
}
@@ -16,12 +16,8 @@ import android.view.inputmethod.InputMethodManager;
public abstract class RustView extends SurfaceView public abstract class RustView extends SurfaceView
implements SurfaceHolder.Callback, Choreographer.FrameCallback { implements SurfaceHolder.Callback, Choreographer.FrameCallback {
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view) // Vendored from android-view bec6c62. The only local change is `protected`,
// with one deliberate change: `protected` rather than package-private, so a // allowing IrisView to forward insets through this native peer.
// subclass in a different package (dev.iris.android.demo.IrisView) can pass
// it to the window-insets native call android-view itself has no hook for --
// see iris/src/android/insets.rs's doc comment for why that call exists at
// all. No other line differs from upstream.
protected final long mViewPeer; protected final long mViewPeer;
final InputMethodManager mInputMethodManager; final InputMethodManager mInputMethodManager;
File renamed without changes.
File renamed without changes.
-226
View File
@@ -1,226 +0,0 @@
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.ktfmt)
}
// Formatting is the formatter's. The one setting is which of ktfmt's two
// styles: kotlinlang is the 4-space one, which is what this code already
// is -- picking the 2-space default would have reindented every file to
// say nothing. Everything else stays at ktfmt's defaults, deliberately.
//
// ./gradlew :androidApp:ktfmtFormat to apply
// ./gradlew :androidApp:ktfmtCheck to verify
ktfmt { kotlinLangStyle() }
// The CA this app pins is baked in at build time from the certificates on
// the machine doing the build -- `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`,
// which the server generates on first start. AI_APP_CA overrides the path.
//
// Reading it rather than keeping a pasted copy in the source is what makes
// the trust boundary follow the build: an APK built on the backend host
// pins the host's CA and never sees any other, while one built in the dev
// VM pins that VM's throwaway CA and is only good for its emulator. There
// is no second trust anchor to get wrong, and no stale paste to notice
// three days later. It also means the private key never has to exist
// anywhere near this repo.
val pinnedCaPath: String =
System.getenv("AI_APP_CA")
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
"/ai-app/certs/ca.pem"
abstract class GeneratePinnedCert : DefaultTask() {
/** Where the certificate is looked for, reported in failures. */
@get:Input abstract val caPath: Property<String>
/**
* The certificate itself, set only when it exists -- so a missing one produces this task's own
* instructions rather than Gradle's "no such input file", which doesn't say what to run.
*/
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.NONE)
abstract val caCertificate: RegularFileProperty
/** Wired by AGP through `addGeneratedSourceDirectory`. */
@get:OutputDirectory abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val path = caPath.get()
val ca = File(path)
if (!ca.isFile) {
throw GradleException(
"No CA certificate at $path.\n" +
"Start ai-server once on this machine first -- it generates the CA the " +
"app pins, and the certificate has to exist before an APK can embed it.\n" +
"Set AI_APP_CA=/path/to/ca.pem to build against a different one."
)
}
val pem = ca.readText().trim()
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
throw GradleException("$path is not a PEM certificate.")
}
val file = outputDir.get().file("PinnedCaCertificate.kt").asFile
file.parentFile.mkdirs()
// The PEM must start immediately after the opening quotes: a
// leading newline makes Android's CertificateFactory stop
// recognising the "-----BEGIN" preamble and try to parse the whole
// thing as DER, which fails with an ASN.1 decode error at runtime
// rather than anywhere near this file.
file.writeText(
"""
|// Generated from $path by the generatePinnedCert task. Do not edit.
|package com.example.aiapp
|
|const val PINNED_CA_PEM = ""${'"'}$pem
|""${'"'}
|
"""
.trimMargin()
)
}
}
val generatePinnedCert =
tasks.register<GeneratePinnedCert>("generatePinnedCert") {
val ca = file(pinnedCaPath)
caPath.set(pinnedCaPath)
if (ca.isFile) {
caCertificate.set(ca)
}
}
android {
namespace = "com.example.aiapp"
compileSdk = 37
defaultConfig {
applicationId = "com.example.aiapp"
minSdk = 24
targetSdk = 37
versionCode = 1
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 {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
// The one native library here is AndroidX's, a few hundred kilobytes with its symbols.
// Stripping them needs an NDK the release build would otherwise not use; keeping them
// is declared so AGP stops warning that it could not.
jniLibs { keepDebugSymbols += "**/libandroidx.graphics.path.so" }
}
// A release build must be signed, and the key is per machine rather than per repo: it is
// what the phone recognises the app by, and a secret never lives in a checkout (the mount is
// shared with an untrusted VM). build-apk.sh keeps it beside the pinned CA and points here
// through the environment; without it the release build is unsigned, which is fine for
// everything except installing.
val keystore = System.getenv("AI_APP_KEYSTORE")
signingConfigs {
if (keystore != null) {
create("release") {
storeFile = file(keystore)
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
keyAlias = "ai-app"
keyPassword = storePassword
}
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
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 {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
// minSdk is 24 and UsageScreen formats its countdown with
// java.time, which the platform only has from 26. Without this it
// is a NoClassDefFoundError on 24 and 25 -- an Error, so the
// catch around that code does not stop it.
isCoreLibraryDesugaringEnabled = true
}
}
// AGP 9 wants generated sources registered through the variant API rather
// than added to a source set, so the task dependency is carried properly.
androidComponents {
onVariants { variant ->
variant.sources.java?.addGeneratedSourceDirectory(
generatePinnedCert,
GeneratePinnedCert::outputDir,
)
}
}
dependencies {
// The link both this app and Dev Updater's need in order to reach a
// machine they were enrolled against: the pinned CA, the enrollment
// store, and the QR capture activity. See wg-app-link's README.
implementation(project(":link"))
// Not a library this code calls: it is what `isCoreLibraryDesugaring
// Enabled` above rewrites java.time against, so API 24 and 25 have it.
coreLibraryDesugaring(libs.desugar.jdk.libs)
implementation(libs.compose.runtime)
implementation(libs.compose.runtime.tracing)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.zxing.embedded)
implementation(libs.markdown.renderer)
implementation(libs.androidx.exifinterface)
// The syntax scanner (Highlighter.kt) is pure logic with no Android imports, which is what
// lets it be tested on the JVM: `./gradlew :androidApp:testDebugUnitTest`. The assertions are
// `kotlin.test`, so the tests name no framework; JUnit is what runs them.
testImplementation(libs.kotlin.test.junit5)
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)
}
// JUnit 6 runs on the Platform, which is not Gradle's default for a Test task.
tasks.withType<Test>().configureEach { useJUnitPlatform() }
-123
View File
@@ -1,123 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Android 17 (API 37) made Local Network Protection mandatory: an app
targeting 37+ needs this runtime permission to reach *any* local
network address, including a plain socket to a LAN IP literal.
Without it the traffic is silently dropped, surfacing only as a
connect timeout. See MainActivity.kt's runtime request, and
dev-updater's manifest for the full story. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Telling somebody a session wants them. POST_NOTIFICATIONS is a
runtime permission from Android 13; the foreground-service pair
below is what lets the connection outlive the app being closed,
which is the entire point (see Notifications.kt). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- tools:ignore MissingApplicationIcon: there is no icon yet, and
that is a decision rather than an oversight. An app with no icon
of its own is obvious to anyone who opens a launcher, so the
warning tells nobody here anything they cannot already see, and
the fix is a judgement about how this app should look. Drop this
suppression when a real icon lands. -->
<application
android:label="@string/app_name"
android:allowBackup="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
tools:ignore="MissingApplicationIcon">
<!-- Lets the phone's own System Tracing see this app's trace sections
(Compose's phases, and the composable names runtime-tracing
adds) in a release build, so a frame cost measured on the real
device can be attributed. shell="true" limits it to profilers
run from the shell; it grants nothing to other apps. -->
<profileable android:shell="true" tools:targetApi="q" />
<!-- adjustResize (not the system's default pan): the layout handles
the keyboard itself via imePadding(), so the window must resize
rather than slide the top bar off screen.
stateUnchanged: coming back to the app leaves the keyboard as it
was left. The default, stateUnspecified, lets the system decide,
and what it decides with a focused message field is to open the
keyboard, so switching away and back covered half the transcript
somebody had switched away to compare against. Unchanged rather
than hidden, because a keyboard that was up when the app was left
is one somebody was in the middle of typing into. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize|stateUnchanged"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Enrollment: the server prints its aiapp://enroll QR to the
terminal. This intent filter is the fallback path for a
camera app that redirects a scanned aiapp:// URI here
directly; the Settings screen's own "Scan QR code" button
(zxing-android-embedded) is the primary path and needs no
filter, since it decodes the QR itself and hands the URI
to parseEnrollmentUri in-process. -->
<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>
<!-- The share sheet: a file, a photo or some text from another app
lands here and is attached to a session (see Share.kt). Any
type, because what a session can be handed is the server's
decision rather than the sheet's. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<!-- specialUse rather than dataSync, which is the type this looks
like: Android 15 caps dataSync at six hours a day, and a
connection that stops listening after six hours is one that
misses the overnight run it exists for. The subtype below is
the reason string that type requires. -->
<service
android:name=".NotificationService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Holds one connection to the user's own backend so a session
that needs an answer can be reported while the app is closed. There is no
push service: the backend is reachable only over the user's WireGuard
tunnel and never talks to a third party." />
</service>
<!-- The scanner behind Settings' "Scan QR code". Declared here so
it can drop the library CaptureActivity's landscape pin: the
code being scanned is usually on a monitor in front of someone
holding the phone upright. zxing_CaptureTheme is the library's
own fullscreen theme, which is all the activity needs. -->
<!-- tools:ignore DiscouragedApi: lint flags every fixed
screenOrientation, because Android 16 ignores most of them.
This one is not a pin but its removal. fullSensor is what
drops the library's landscape lock, so the activity follows
the phone rather than asking anyone to turn it, and where the
platform ignores the attribute the behaviour is the one this
asked for anyway. Scoped to this activity, so a genuine pin
elsewhere would still be reported. -->
<activity
android:name="com.example.wgapplink.EnrollmentScanActivity"
android:clearTaskOnLaunch="true"
android:screenOrientation="fullSensor"
android:stateNotNeeded="true"
android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden"
tools:ignore="DiscouragedApi" />
</application>
</manifest>
@@ -1,311 +0,0 @@
package com.example.aiapp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
/**
* The sixteen colours a terminal program names, and the two it assumes.
*
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
* highlighter's colours are this app's reading of somebody else's code. They come out of the same
* Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one
* table -- adding a syntax role to this list would silently move `ls`'s directory blue.
*/
data class AnsiPalette(
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
val colours: List<Color>,
/** What uncoloured text is, needed only where a style has to state a colour. */
val foreground: Color,
/** What the text sits on, needed for reverse video. */
val background: Color,
)
/**
* What a tool printed, with its terminal styling applied and everything else taken out.
*
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
* verbatim those are line noise in the middle of the thing being read. Stripping them all would be
* the other half-answer -- colour is often the whole of what a diff or a test run is saying.
*
* So the sequences that decide how text *looks* become spans, and every other one is dropped rather
* than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no
* meaning in a scrolling document.
*
* A carriage return is honoured the way a terminal honours it: what was written since the last line
* break is thrown away and the line starts again. That is what makes a progress bar show its final
* state rather than every state it passed through.
*
* Not a composable, and the palette is a parameter, so this can be remembered against the text it
* parsed rather than re-run on every recomposition of the card holding it.
*/
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
// The common case by a long way -- nothing to do, and nothing allocated to find that out.
if (text.indexOf(ESC) < 0 && text.indexOf('\r') < 0) return AnnotatedString(text)
val runs = mutableListOf<Run>()
var sgr = Sgr.PLAIN
var at = 0
val plain = StringBuilder()
fun flush() {
if (plain.isNotEmpty()) {
runs.add(Run(plain.toString(), sgr.span(palette)))
plain.clear()
}
}
while (at < text.length) {
val c = text[at]
when {
c == ESC -> {
flush()
at =
skipEscape(text, at) { params, final ->
if (final == 'm') sgr = sgr.apply(params, palette)
}
}
// A bare carriage return rewrites the line. One before a newline is the other half of a
// Windows line ending: it rewrites nothing, and it is dropped rather than kept, since
// that pair is one line break.
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
flush()
dropLine(runs)
at++
}
c == '\r' -> at++
// Everything printable, plus the two control characters that are layout rather than
// terminal commands. A stray bell or backspace goes for the same reason a cursor move
// does.
c >= ' ' || c == '\n' || c == '\t' -> {
plain.append(c)
at++
}
else -> at++
}
}
flush()
return buildAnnotatedString {
runs.forEach { run ->
if (run.style == null) {
append(run.text)
} else {
val pushed = pushStyle(run.style)
append(run.text)
pop(pushed)
}
}
}
}
/** One stretch of text that shares a style. */
private class Run(val text: String, val style: SpanStyle?)
/** Throws away everything written since the last line break, as a carriage return does. */
private fun dropLine(runs: MutableList<Run>) {
while (runs.isNotEmpty()) {
val last = runs.removeAt(runs.size - 1)
val breakAt = last.text.lastIndexOf('\n')
if (breakAt >= 0) {
runs.add(Run(last.text.substring(0, breakAt + 1), last.style))
return
}
}
}
private const val ESC = '\u001B'
private const val BELL = '\u0007'
/**
* 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.
*/
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
val next = text.getOrNull(at + 1) ?: return at + 1
return when (next) {
'[' -> {
var end = at + 2
while (end < text.length && text[end] !in CSI_FINAL) end++
if (end >= text.length) {
// Cut off mid-sequence, which is what a stream that has not finished arriving looks
// like: drop the fragment rather than printing it, and the whole sequence arrives
// with the next delta.
text.length
} else {
onCsi(text.substring(at + 2, end), text[end])
end + 1
}
}
']',
'P',
'X',
'^',
'_' -> {
// Runs to a string terminator: `ESC \`, or the bell that xterm allows after an OSC.
var end = at + 2
while (end < text.length) {
if (text[end] == BELL) return end + 1
if (text[end] == ESC && text.getOrNull(end + 1) == '\\') return end + 2
end++
}
text.length
}
else -> at + 2
}
}
/** The bytes that end a CSI sequence. */
private val CSI_FINAL = '@'..'~'
/** Everything an SGR sequence can turn on, as the terminal tracks it. */
private data class Sgr(
val fg: Color?,
val bg: Color?,
val bold: Boolean,
val dim: Boolean,
val italic: Boolean,
val underline: Boolean,
val strike: Boolean,
val reverse: Boolean,
) {
/** Null while nothing is set, so unstyled output costs no spans at all. */
fun span(palette: AnsiPalette): SpanStyle? {
if (this == PLAIN) return null
val front = if (reverse) bg ?: palette.background else fg
val back = if (reverse) fg ?: palette.foreground else bg
// Dim has to have a colour to dim, so where none was named it dims the ordinary one.
val stated = front ?: palette.foreground.takeIf { dim }
return SpanStyle(
color =
stated?.let { if (dim) it.copy(alpha = DIM_ALPHA) else it } ?: Color.Unspecified,
background = back ?: Color.Unspecified,
fontWeight = if (bold) FontWeight.Bold else null,
fontStyle = if (italic) FontStyle.Italic else null,
textDecoration =
when {
underline && strike ->
TextDecoration.combine(
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
)
underline -> TextDecoration.Underline
strike -> TextDecoration.LineThrough
else -> null
},
)
}
/**
* This state with [params] applied -- one `ESC[…m`, which carries any number of them.
*
* A code this does not model is ignored rather than reset from: the program meant something by
* it, and starting again would also drop the codes beside it that are understood.
*/
fun apply(params: String, palette: AnsiPalette): Sgr {
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a zero too.
val codes = params.split(';').map { it.trim().toIntOrNull() ?: 0 }
var state = this
var at = 0
while (at < codes.size) {
val code = codes[at]
state =
when (code) {
0 -> PLAIN
1 -> state.copy(bold = true)
2 -> state.copy(dim = true)
3 -> state.copy(italic = true)
4 -> state.copy(underline = true)
7 -> state.copy(reverse = true)
9 -> state.copy(strike = true)
21,
22 -> state.copy(bold = false, dim = false)
23 -> state.copy(italic = false)
24 -> state.copy(underline = false)
27 -> state.copy(reverse = false)
29 -> state.copy(strike = false)
in 30..37 -> state.copy(fg = palette.colours[code - 30])
in 90..97 -> state.copy(fg = palette.colours[code - 90 + 8])
in 40..47 -> state.copy(bg = palette.colours[code - 40])
in 100..107 -> state.copy(bg = palette.colours[code - 100 + 8])
39 -> state.copy(fg = null)
49 -> state.copy(bg = null)
38,
48 -> {
val (colour, last) = extendedColour(codes, at, palette)
at = last
if (code == 38) state.copy(fg = colour) else state.copy(bg = colour)
}
else -> state
}
at++
}
return state
}
companion object {
val PLAIN =
Sgr(
fg = null,
bg = null,
bold = false,
dim = false,
italic = false,
underline = false,
strike = false,
reverse = false,
)
}
}
/** How much of its colour dim text keeps: enough to read, little enough to recede. */
private const val DIM_ALPHA = 0.65f
/**
* The colour named by a `38`/`48` at [at], and the index of that colour's last parameter.
*
* Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal one. The first sixteen of
* that table are the palette's own, so a program asking for "colour 1" through either spelling gets
* the same red.
*/
private fun extendedColour(codes: List<Int>, at: Int, palette: AnsiPalette): Pair<Color?, Int> =
when (codes.getOrNull(at + 1)) {
5 -> {
val n = codes.getOrNull(at + 2)
if (n == null) null to at + 1 else indexedColour(n, palette) to at + 2
}
2 -> {
val r = codes.getOrNull(at + 2)
val g = codes.getOrNull(at + 3)
val b = codes.getOrNull(at + 4)
if (r == null || g == null || b == null) null to at + 1
else Color(r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255)) to at + 4
}
else -> null to at + 1
}
/** One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a grey ramp. */
private fun indexedColour(n: Int, palette: AnsiPalette): Color =
when {
n < 0 -> palette.foreground
n < 16 -> palette.colours[n]
n < 232 -> {
val i = n - 16
Color(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
}
n < 256 -> {
val grey = 8 + (n - 232) * 10
Color(grey, grey, grey)
}
else -> palette.foreground
}
/** The six levels of each channel in the 256-colour cube, as xterm defines them. */
private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255)
File diff suppressed because it is too large. Load diff
@@ -1,297 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.example.wgapplink.localNetworkAllowed
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 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.
*
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
* them a step down from another -- and what is left here is only what genuinely is a step down: one
* session, spawning one, and settings.
*/
private sealed class 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 subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* is opened from the session list's card rather than from inside the session it belongs to.
*/
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
/**
* One session, with the file explorer over it when [files] is set.
*
* 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 Settings : Screen()
}
/**
* A session a notification tap asked to open, before it is a screen.
*
* The notification names an id and nothing else, so opening it means fetching the session first.
* [serial] tells two taps on the same session's notification apart, since they are two requests and
* would otherwise compare equal.
*/
data class SessionOpenRequest(val sessionId: String, val serial: Int)
/** A tap that could not be turned into a screen, kept with its request so Try again knows what. */
private data class FailedOpen(val request: SessionOpenRequest, val message: String)
/**
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), re-
* reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
*
* [openRequest] is the session a notification tap asked for, likewise from MainActivity.
*
* [shareRequest] is what another app shared in, likewise. It is held here until a session takes it,
* because the share arrives before anyone has said which session it is for.
*/
@Composable
fun AppRoot(
settingsVersion: Int,
openRequest: SessionOpenRequest?,
shareRequest: ShareRequest? = null,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
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
// after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
// Bumped whenever another screen changes something the list shows, so returning to it
// refetches.
var reloadToken by remember { mutableIntStateOf(0) }
// Cleared by the session screen that attached it, not when a newer request arrives: a share
// must be attached exactly once, and only the screen that did it knows that it has.
var share by remember { mutableStateOf<ShareRequest?>(null) }
LaunchedEffect(shareRequest) {
if (shareRequest != null) {
share = shareRequest
// 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.
if (screen !is Screen.Session) screen = Screen.Main()
}
}
// A standing condition rather than a per-request failure, so it is stated once here instead of
// appended to every error it might cause. Without this the app is simply unreachable and every
// screen blames the server or the tunnel for it.
if (!localNetworkAllowed(context)) {
Text(
"This app is not allowed to reach local network addresses, so it cannot " +
"connect to the backend at all. Grant \"local network\" in Android's app " +
"settings; until then every screen here will look like the server is down.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp),
)
}
val current = settings
if (current == null) {
// Not enrolled yet: settings is the only usable screen. The QR path lands in MainActivity
// and recomposes from the top.
Box(Modifier.imePadding()) {
SettingsScreen(
existing = null,
onSaved = { saved ->
settings = saved
screen = Screen.Main()
},
onBack = null,
)
}
return
}
// The one way back, whichever screen is showing and whether it was reached by the system back
// gesture or a screen's own Back button. Every leaf screen can have changed something the list
// shows, so it always refetches.
val goToMain = {
reloadToken++
screen = Screen.Main()
}
if (screen !is Screen.Main) {
BackHandler(onBack = goToMain)
}
// Turning a notification into the screen it points at. The id has to be resolved to a session
// first, because that is what SessionScreen is given -- and unlike a list row, there is nothing
// here to seed it from.
//
// A failure is reported rather than swallowed: somebody deliberately tapped a notification, so
// an app that opens to the session list with no explanation looks like the tap missed.
val open: suspend (SessionOpenRequest) -> Unit = { request ->
failedOpen = null
try {
val session = withContext(Dispatchers.IO) { fetchSession(current, request.sessionId) }
screen = Screen.Session(session)
} catch (e: ApiException) {
failedOpen = FailedOpen(request, e.message ?: "Unknown error")
}
}
LaunchedEffect(openRequest) { openRequest?.let { open(it) } }
val failed = failedOpen
if (failed != null) {
AlertDialog(
onDismissRequest = { failedOpen = null },
title = { Text("Couldn't open that session") },
text = { Text(failed.message) },
confirmButton = {
TextButton(onClick = { scope.launch { open(failed.request) } }) {
Text("Try again")
}
},
dismissButton = { TextButton(onClick = { failedOpen = null }) { Text("Cancel") } },
)
}
// Every screen but the session takes the keyboard as bottom padding here. The session screen
// 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.
when (val here = screen) {
is Screen.Main ->
Box(Modifier.imePadding()) {
MainScreen(
settings = current,
reloadToken = reloadToken,
share = share,
onOpen = { screen = Screen.Session(it) },
onOpenSubagent = { summary, subagent ->
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
},
onSpawn = { screen = Screen.Spawn },
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings },
)
// 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 ->
// Keyed on the id, because a different session is a different screen rather than this
// one showing other rows. SessionScreen remembers a transcript, an open stream, a draft
// and a scroll position, and without the key Compose keeps all of it across the change
// and merges two conversations -- which crashes the list on the first duplicate row
// key. Only reachable since a notification can move straight from one session to
// another.
key(here.summary.id) {
// 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.
Box {
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToMain,
onFiles = { screen = here.copy(files = it) },
share = share,
onShareTaken = { share = null },
)
// 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.
here.files?.let { target ->
FilesScreen(
settings = current,
target = target,
onClose = { screen = here.copy(files = null) },
)
}
}
}
is Screen.Spawn ->
Box(Modifier.imePadding()) {
SpawnScreen(
settings = current,
onSpawned = { spawned ->
reloadToken++
screen = Screen.Session(spawned)
},
onBack = goToMain,
)
}
is Screen.Settings ->
Box(Modifier.imePadding()) {
SettingsScreen(
existing = current,
onSaved = { saved ->
settings = saved
goToMain()
},
onBack = goToMain,
)
}
}
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
// the activity puts around this, and that Box paints in the order it was given. A session
// wanting attention is not a fact about the page somebody happens to be on. Tapping one is the
// same act as tapping a notification, so it goes through the same `open`.
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
}
@@ -1,392 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
/** One question's answer on its way back, so a card can hand over several at once. */
data class QuestionAnswer(val questionId: String, val answers: List<String>)
/**
* What the reader has settled on for one question, before any of it is sent.
*
* Held here rather than inferred from the transcript, which is what made picking an option feel
* broken: the mark used to appear only when the answer had crossed the tunnel and come back as an
* event, so the card sat unchanged for most of a second after a tap.
*
* Picked options and typed words are one field each because they are alternatives rather than
* parts: typing puts the picks away and picking puts the words away, so there is never a draft that
* means two things.
*/
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
val settled: Boolean
get() = picked.isNotEmpty() || other.isNotBlank()
/**
* What goes back, in the order the options were offered rather than the order they were tapped:
* the reader is answering a list, and it should read back as that list.
*/
fun answers(options: List<QuestionOption>): List<String> =
if (other.isNotBlank()) listOf(other.trim())
else options.map { it.label }.filter { it in picked }
}
/**
* Every question one tool call is waiting on, one at a time.
*
* All of it comes from the question events themselves. None of it is read out of the call's own
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
* app, where no other provider can reach it and where it drifts the first time the schema moves.
*
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
* three questions with four options and a description each is several screens tall, so the reader
* scrolls past the question they are answering to reach the button that sends it. Paged, each
* question is a screen and the count says how many are left.
*
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
* asked them together, and sending each as it was tapped meant the reader could not change their
* mind about the first after reading the third.
*/
@Composable
fun AskUserQuestionBody(
asks: List<TranscriptItem.QuestionCard>,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) {
// Seeded from what was already answered, so a card the reader comes back to shows their answers
// rather than an empty draft over them.
var drafts by
remember(asks.map { it.id }) {
mutableStateOf(
asks.associate { ask ->
ask.id to
Draft(
picked =
ask.answers
.filter { a -> ask.options.any { it.label == a } }
.toSet(),
other =
ask.answers
.firstOrNull { a -> ask.options.none { it.label == a } }
.orEmpty(),
)
}
)
}
var at by remember(asks.map { it.id }) { mutableIntStateOf(0) }
var sending by remember(asks.map { it.id }) { mutableStateOf(false) }
if (asks.isEmpty()) return
val showing = asks[at.coerceIn(0, asks.size - 1)]
val outstanding = asks.filter { it.answers.isEmpty() }
Column(Modifier.fillMaxWidth()) {
if (asks.size > 1) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
"Question ${at + 1} of ${asks.size}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
// Disabled at the ends rather than absent, so the pair keeps its place and the
// reader can see there is nothing further that way.
MarkButton("Previous question", { at-- }, enabled = at > 0) {
Chevron(Pointing.Left, colour = LocalContentColor.current)
}
MarkButton("Next question", { at++ }, enabled = at < asks.size - 1) {
Chevron(Pointing.Right, colour = LocalContentColor.current)
}
}
}
Spacer(Modifier.height(4.dp))
AskedQuestion(
showing,
draft = drafts[showing.id] ?: Draft(),
onDraft = { drafts = drafts + (showing.id to it) },
)
if (outstanding.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
// Greyed until every question has an answer, because the tool is waiting on all of
// them: a submit that sent two of three would leave the third asked and the card
// looking dealt with.
val ready = outstanding.all { drafts[it.id]?.settled == true }
Button(
onClick = {
sending = true
onAnswer(
outstanding.map { ask ->
QuestionAnswer(ask.id, (drafts[ask.id] ?: Draft()).answers(ask.options))
}
) {
// Back to a button whatever happened. A refusal is reported by the screen
// around this, and the draft is still here to send again -- a spinner that
// never stops would be the only sign of a failure this card cannot
// describe.
sending = false
}
},
enabled = ready && !sending,
modifier = Modifier.fillMaxWidth(),
) {
if (sending) {
// In the button rather than beside it, so the row does not change height at the
// moment it is pressed.
CircularProgressIndicator(
Modifier.height(18.dp).width(18.dp),
strokeWidth = 2.dp,
color = LocalContentColor.current,
)
} else {
Text(
if (outstanding.size > 1) "Submit ${outstanding.size} answers" else "Submit"
)
}
}
}
}
}
/**
* One question: what is being asked, what can be answered, and what was.
*
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
* when nothing did. Two renderings of it would be two places for an answer to go missing.
*
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
* sends anything. An answered question ignores both and draws what was answered.
*/
@Composable
fun AskedQuestion(
ask: TranscriptItem.QuestionCard,
draft: Draft,
onDraft: (Draft) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
ask.header?.let { header ->
// Its own line rather than beside the question, because it is a label *for* the
// question and the question is the thing to read.
Text(
header.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp))
// An answered question keeps its options and marks the one that was taken, rather than
// replacing them with a line repeating it. The options are what the question *was*, and
// dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says
// very little without the three it was chosen over. Marked in the same purple that says
// "picked" while the question is open, so it is one appearance learned once.
val answered = ask.answers.isNotEmpty()
// What is marked: what was answered once there is an answer, and what the finger has chosen
// until then.
val marked = if (answered) ask.answers.toSet() else draft.picked
// Null once the question is answered: the options stay and stop being pressable.
val onPick: ((String) -> Unit)? =
if (answered) null else { label -> onDraft(pick(draft, label, ask.multiSelect)) }
if (ask.options.all { it.description == null && it.preview == null }) {
// Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
// do not need a card each.
AnswerOptions(ask.options, marked.toList(), onPick)
} else {
ask.options.forEach { option ->
OptionCard(option, selected = option.label in marked) {
onPick?.invoke(option.label)
}
}
}
// What was answered in the reader's own words, which no option can mark. Only ever the
// answers that match nothing offered, so a question answered by picking says it by the
// mark.
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
if (inWords.isNotEmpty()) {
Text(
"Answered: ${inWords.joinToString(", ")}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
}
if (!answered) {
OtherAnswer(draft.other) { onDraft(Draft(other = it)) }
}
}
}
/**
* [label] added to, or taken out of, what [draft] has picked. A single-answer question replaces
* rather than accumulates, and either way picking puts any typed words away -- see [Draft].
*/
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
when {
!multiSelect -> Draft(picked = setOf(label))
label in draft.picked -> Draft(picked = draft.picked - label)
else -> Draft(picked = draft.picked + label)
}
/**
* One option: what it is called, what it means, and what it would produce.
*
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
* indistinguishable from the card behind it -- three paragraphs of text where three things to press
* should have been. A border is one cue and it is unambiguous.
*/
@Composable
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
OutlinedCard(
onClick = onPick,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
colors =
CardDefaults.outlinedCardColors(
containerColor =
if (selected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface
),
// Picked shows in the border as well as the fill, because the fill alone is a colour
// difference somebody has to have seen the unpicked version to notice.
border =
BorderStroke(
if (selected) 2.dp else 1.dp,
if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant,
),
) {
Column(Modifier.padding(12.dp)) {
Text(option.label, style = MaterialTheme.typography.titleSmall)
option.description?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
option.preview?.let { Preview(it) }
}
}
}
/**
* An option's worked example, shown as written.
*
* On its own surface, because it is a different kind of thing from the sentence above it: that
* describes the option, this is a sample of what the option produces, and monospace alone reads as
* a description that happens to be in code font.
*/
@Composable
private fun Preview(preview: String) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLowest,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(
preview,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines of
// the thing being previewed.
softWrap = false,
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
)
}
}
/**
* The choice the asker always leaves open, and the app has to as well.
*
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
* rather than pick. Leaving it out narrows a question that was never that narrow.
*/
@Composable
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
// what sends it. A second send button beside the field made the shorter half of the card look
// like the one that finishes it.
OutlinedTextField(
value = text,
onValueChange = onText,
label = { Text("Other") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
/**
* Bare options, wrapped rather than in a row.
*
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
* with four options showed the first one or two and dropped the rest off the side of the screen.
* That reads as those having been the only choices.
*/
@Composable
fun AnswerOptions(
options: List<QuestionOption>,
/** What is chosen: the answer once there is one, and what the finger has marked until then. */
answers: List<String> = emptyList(),
/** Null once the question is answered -- the buttons stay, and stop being buttons. */
onPick: ((String) -> Unit)?,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
options.forEach { option ->
val taken = option.label in answers
OutlinedButton(
onClick = { onPick?.invoke(option.label) },
// Disabled rather than removed, so an answered question still shows what it
// offered. Material dims a disabled button's own border and label, which would take
// the mark with it -- both are stated here instead.
enabled = onPick != null,
border =
BorderStroke(
if (taken) 2.dp else 1.dp,
if (taken) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant,
),
colors =
ButtonDefaults.outlinedButtonColors(
disabledContentColor =
if (taken) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
),
) {
Text(option.label)
}
}
}
}
@@ -1,65 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* 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
* `media` table, which is the one other place the list lives.
*/
fun isImageRef(ref: String): Boolean = ref.substringAfterLast('.', "") in IMAGE_EXTENSIONS
private val IMAGE_EXTENSIONS = setOf("png", "jpg", "gif", "webp")
/**
* The name a file was attached under: the ref less the hex the server put before it. The hex has no
* dash in it, so the first one is the boundary however many the name has.
*/
fun attachmentName(ref: String): String = ref.substringAfter('-', ref)
/**
* One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A
* file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all
* of it.
*/
@Composable
fun Attachment(
settings: ServerSettings,
sessionId: String,
ref: String,
onOpenImage: (String) -> Unit,
) {
if (isImageRef(ref)) SessionImage(settings, sessionId, ref, onOpenImage)
else
FileName(
attachmentName(ref),
Modifier.clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
/**
* A file's name, one line, in the face names are read in. Overlong names lose their middle: a name
* is identified by both ends -- what it is at the front, what kind at the back.
*/
@Composable
fun FileName(name: String, modifier: Modifier = Modifier) {
Text(
name,
modifier = modifier,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
@@ -1,171 +0,0 @@
package com.example.aiapp
import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import android.provider.OpenableColumns
import androidx.exifinterface.media.ExifInterface
import java.io.ByteArrayOutputStream
import kotlin.math.max
/**
* Getting a picked photo to a session, at a size the session can actually take.
*
* A phone camera produces twelve megapixels and several megabytes. The Claude API resizes anything
* larger than 1568px on its long edge before looking at it and refuses images past a much higher
* bound outright, so a photo sent straight off the camera roll was uploaded whole over the tunnel
* to be either thrown away or rejected -- which is what "sending an image is broken" was.
*
* Shrunk here rather than on the backend, so the bytes that never mattered are never sent: the
* expensive part on a phone is the upload, not the decode. What the limit *is* comes from the
* server, per session, because that is where a provider's requirements are known.
*/
suspend fun uploadPickedImage(
context: Context,
settings: ServerSettings,
sessionId: String,
uri: Uri,
maxEdge: Int?,
): String {
val (bytes, mime) = readForUpload(context, uri, maxEdge)
return uploadAttachment(settings, sessionId, mime, "image") { it.write(bytes) }
}
/**
* 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
* it, because the session is told that name rather than shown the bytes.
*/
suspend fun uploadPicked(
context: Context,
settings: ServerSettings,
sessionId: String,
uri: Uri,
maxEdge: Int?,
): String {
val resolver = context.contentResolver
val mime = resolver.getType(uri)
if (mime != null && mime.startsWith("image/")) {
return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
}
// Opened before the request starts, so a provider that refuses says so here and not from inside
// the connection; then streamed, since a trace is bigger than this process should hold at once.
val source = openSource(resolver, uri)
val name = displayName(resolver, uri)
return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out ->
try {
source.use { it.copyTo(out, COPY_BUFFER) }
} catch (e: java.io.IOException) {
// Either side of the copy can fail; the message names the file, which is the part the
// reader can do something about.
throw ApiException("couldn't send $name: ${e.message}", cause = e)
}
}
}
private const val COPY_BUFFER = 64 * 1024
/**
* A stream of [uri], or the refusal as the kind the composer reports beside the message.
*
* A share arrives with whatever access the other app granted, and a provider that refuses says so
* with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both
* are things the reader can act on.
*/
private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream =
try {
resolver.openInputStream(uri)
?: throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: nothing there")
} catch (e: SecurityException) {
throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: no access to it")
} catch (e: java.io.IOException) {
throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: ${e.message}")
}
/** Everything at [uri]; an image is decoded whole anyway, so it is read whole. */
private fun readAll(resolver: ContentResolver, uri: Uri): ByteArray =
openSource(resolver, uri).use { it.readBytes() }
/**
* The name a document provider shows for [uri]. The last path segment is the fallback because a
* provider's own id for a file is usually a number, which says nothing to the session.
*/
private fun displayName(resolver: ContentResolver, uri: Uri): String {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (column >= 0 && cursor.moveToFirst())
cursor.getString(column)?.let {
return it
}
}
return uri.lastPathSegment ?: "file"
}
/**
* The bytes to upload and what they are, scaled down only if they need to be.
*
* An image already inside the limit is uploaded exactly as it came, rather than decoded and re-
* encoded to the same size: a round trip through JPEG loses a little every time. This is also the
* path a provider with no limit always takes.
*/
private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteArray, String> {
val resolver = context.contentResolver
val mime = resolver.getType(uri) ?: "image/jpeg"
val original = readAll(resolver, uri)
if (maxEdge == null) return original to mime
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(original, 0, original.size, bounds)
val longest = max(bounds.outWidth, bounds.outHeight)
// outWidth is -1 when the bytes are not an image this device can decode. Sent on untouched:
// this function's job is the size, and refusing something the server might understand is a
// decision it has no business making.
if (longest <= 0 || longest <= maxEdge) return original to mime
// Powers of two first, which is all the decoder can do, and then the exact scale. Decoding the
// full twelve megapixels only to shrink it is how this runs out of memory on the images it most
// needs to handle.
val decode =
BitmapFactory.Options().apply {
inSampleSize = Integer.highestOneBit(max(1, longest / maxEdge))
}
val decoded =
BitmapFactory.decodeByteArray(original, 0, original.size, decode) ?: return original to mime
val scale = maxEdge.toFloat() / max(decoded.width, decoded.height)
val matrix = Matrix()
if (scale < 1f) matrix.postScale(scale, scale)
// The camera writes which way up the picture is into EXIF rather than rotating the pixels, and
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side.
// Applied to the same matrix as the scale, so it costs no second copy of the bitmap.
matrix.postRotate(exifRotation(original))
val scaled = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
val out = ByteArrayOutputStream()
// JPEG whatever came in: this is a photograph being made smaller, which is what JPEG is for,
// and a PNG of a resampled photo is several times the size for no visible difference.
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
return out.toByteArray() to "image/jpeg"
}
/** How far to turn the picture so it is the way up it was taken. */
private fun exifRotation(bytes: ByteArray): Float =
try {
when (
ExifInterface(bytes.inputStream())
.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
} catch (_: java.io.IOException) {
// No EXIF, or none this can read. Upright is the assumption every image without the tag is
// displayed under anyway.
0f
}
/** High enough that resampling is what the reader notices, not the encoder. */
private const val JPEG_QUALITY = 90
@@ -1,108 +0,0 @@
package com.example.aiapp
import android.content.Context
import java.util.concurrent.CopyOnWriteArrayList
/**
* P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): an in-process
* fake of the backend, so the `bench` build type can drive a real session screen -- the real
* [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 = 3200
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 }
}
}
@@ -1,181 +0,0 @@
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
}
}
@@ -1,325 +0,0 @@
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,51 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
// The composer's row of settings and pickers, and the menus they open. One file because the outline
// and the corner are one appearance: a control shaped like this opens a surface shaped like this.
/**
* A bordered pill: a control that can be seen without being pressed.
*
* The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at
* all until they are touched. Three bare words under the message field read as a caption about the
* field rather than as three things to press. The outline says "control" without the weight of a
* filled button, which is reserved for the two that act on the session.
*/
@Composable
fun BubbleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
shape = BubbleShape,
// A text button's padding rather than a filled button's 24dp: these sit three across under
// the message field, and the wider padding is what decides whether the row fits.
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier,
) {
content()
}
}
/** Fully round ends, so the control reads as a bubble rather than as a box. */
val BubbleShape: Shape = RoundedCornerShape(percent = 50)
/**
* The corner on a menu one of these opens.
*
* A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding
* ends that tall would bow its sides.
*/
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
@@ -1,95 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
/**
* An item something is happening to: dimmed, drained of colour, inert, with a spinner and the name
* of the operation over it.
*
* One composable rather than a pattern each list repeats, because "this row is busy" has to look
* the same in the import list and the session list or the appearance becomes a per-screen dialect.
*
* [label] names the operation and `null` means none is running. One parameter rather than a boolean
* beside a string, which can disagree. It is a *word* because a spinner alone cannot say which
* operation this is -- deleting and importing are different in kind.
*
* It does **not** make the row inert; the caller disables its own click handling while it passes a
* label. That was the other way round at first -- an overlay consuming pointer events -- and it
* swallowed the drag along with the tap, so a list could not be scrolled while anything in it was
* busy.
*/
@Composable
fun BusyItem(label: String?, content: @Composable () -> Unit) {
Box {
Box(Modifier.busy(label != null)) { content() }
if (label != null) {
Box(Modifier.matchParentSize(), contentAlignment = Alignment.Center) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.width(8.dp))
// Full strength, over content that is not: the operation is the one thing on
// this row that is still current, and it has to read against a card whose own
// text is still visible behind it.
Text(
label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
/**
* How an item looks while it is being acted on: darker, and nearly grey.
*
* Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a
* busy row read as one more thing that could not be tapped. Draining the colour is what says the
* row is *suspended* -- the status word and everything else that means something by its colour stop
* meaning it for as long as the operation runs, which is exactly true.
*
* Not all the way to grey: a row with no colour left is hard to find again in a list.
*/
private fun Modifier.busy(busy: Boolean): Modifier =
if (!busy) this
else
this.graphicsLayer { alpha = 0.5f }
.drawWithContent {
drawIntoCanvas { canvas ->
canvas.saveLayer(
Rect(Offset.Zero, size),
Paint().apply {
colorFilter =
ColorFilter.colorMatrix(
ColorMatrix().apply { setToSaturation(0.2f) }
)
},
)
drawContent()
canvas.restore()
}
}
@@ -1,69 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
/** Which way a [Chevron] points. */
enum class Pointing {
Up,
Down,
Left,
Right,
}
/**
* A chevron, pointing whichever of the four ways is asked for.
*
* Drawn rather than set in a font: a chevron from an icon font is one of the glyphs a system font
* may simply not have, and the reader who gets an empty box instead is never the one who wrote it.
*
* One composable for all four directions rather than one per axis that differ by which coordinate
* gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one
* direction. The shape is written once in its own coordinates, and [Pointing] is only a table of
* how those map onto the box.
*
* It draws no label of its own, so every caller owes it a `contentDescription`.
*/
@Composable
fun Chevron(
pointing: Pointing,
modifier: Modifier = Modifier,
colour: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
val sideways = pointing == Pointing.Left || pointing == Pointing.Right
Canvas(
modifier
.width(if (sideways) CHEVRON_DEPTH else CHEVRON_SPAN)
.height(if (sideways) CHEVRON_SPAN else CHEVRON_DEPTH)
) {
val inset = 2.dp.toPx()
val wide = size.width - inset
val tall = size.height - inset
fun at(across: Float, along: Float) =
when (pointing) {
Pointing.Up -> Offset(lerp(inset, wide, across), lerp(tall, inset, along))
Pointing.Down -> Offset(lerp(inset, wide, across), lerp(inset, tall, along))
Pointing.Left -> Offset(lerp(wide, inset, along), lerp(inset, tall, across))
Pointing.Right -> Offset(lerp(inset, wide, along), lerp(inset, tall, across))
}
val stroke = 2.dp.toPx()
drawLine(colour, at(0f, 0f), at(0.5f, 1f), strokeWidth = stroke, cap = StrokeCap.Round)
drawLine(colour, at(0.5f, 1f), at(1f, 0f), strokeWidth = stroke, cap = StrokeCap.Round)
}
}
private fun lerp(from: Float, to: Float, fraction: Float) = from + (to - from) * fraction
/** How far the chevron opens, across the direction it points. */
private val CHEVRON_SPAN = 20.dp
/** How far it reaches in the direction it points. */
private val CHEVRON_DEPTH = 10.dp
@@ -1,220 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.model.State
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.ast.getTextInNode
/**
* A fenced code block in a reply: the code highlighted, on the dark surface every verbatim thing
* sits on, scrolling sideways rather than wrapping.
*
* The renderer's own fence drew the same block in plain text. The scanner that colours a tool
* call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool
* wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than
* wrongly coloured.
*
* Finding the code is still the library's: which children of the node are the fence markers, the
* language word and the code between them is its knowledge of the parser.
*/
@Composable
fun CodeFence(
content: String,
node: ASTNode,
style: TextStyle,
replies: ParsedReplies,
streaming: Boolean = false,
) {
val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return
CodeBlockText(code, language, style, replies, streaming)
}
/** An indented code block, which is a fence with no language word. */
@Composable
fun CodeBlock(
content: String,
node: ASTNode,
style: TextStyle,
replies: ParsedReplies,
streaming: Boolean = false,
) {
val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return
CodeBlockText(code, language, style, replies, streaming)
}
/**
* The code inside a fence or indented block, and the highlighter's language for its info word.
*
* Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and
* the whole point here is that [warm] can run this on a background thread and highlight the same
* string the drawing will ask for. Two extractions would be two keys, and the warmed answer would
* be silently missed at every fence.
*
* Null for a fence too short to hold anything -- an unterminated one still arriving.
*/
fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
val word =
node.findChildOfType(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)?.toString()
val language = fenceLanguage(word)
if (node.type == MarkdownElementTypes.CODE_BLOCK) {
val start = node.children.firstOrNull()?.startOffset ?: return null
val end = node.children.lastOrNull()?.endOffset ?: return null
return content.substring(start, end).replaceIndent() to language
}
if (node.children.size < 3) return null
val start = node.children[2].startOffset
val fenceCount = if (word != null && node.children.size > 3) 3 else 2
val end = node.children[(node.children.size - 2).coerceAtLeast(fenceCount)].endOffset
return content.substring(start, end).replaceIndent() to language
}
/**
* Plain while [streaming], coloured once the block is finished; see [MarkdownRoot].
*
* The renderer's own block, less what nothing here needs: the same background, corner, padding and
* sideways scroll, without the shadow, the border and the empty pointer handler it also carried.
*/
@Composable
private fun CodeBlockText(
code: String,
language: Language?,
style: TextStyle,
replies: ParsedReplies,
streaming: Boolean,
) {
val colors = LocalMarkdownColors.current
val dimens = LocalMarkdownDimens.current
val padding = LocalMarkdownPadding.current
Box(
Modifier.fillMaxWidth()
.padding(vertical = 8.dp)
.background(colors.codeBackground, RoundedCornerShape(dimens.codeBackgroundCornerSize))
.semantics { isTraversalGroup = true }
) {
BasicText(
// No language while the block is still being written, which is what draws it plain.
replies.highlighted(code, language.takeUnless { streaming }),
style = style,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
)
}
}
/**
* The highlighter's language for a fence's info word, or null for one it has no rules for.
*
* The aliases are what people actually write after the backticks: the file extension as often as
* the name. A word not here gets no colour rather than the nearest language's, because a fence
* coloured by the wrong language's rules looks highlighted and is wrong in a way the reader cannot
* see.
*/
fun fenceLanguage(name: String?): Language? =
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
/**
* The highlighter's language for a *file*, from its name.
*
* The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people
* write after the backticks. One table rather than two, so a language added for fences is a
* language added for files and neither can be the one somebody forgot.
*
* 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 null, and null is drawn plain.
*/
fun fileLanguage(name: String): Language? {
val dot = name.lastIndexOf('.')
if (dot < 1) return null
return fenceLanguage(name.substring(dot + 1))
}
private val FENCE_LANGUAGES: Map<String, Language> =
mapOf(
"kotlin" to Language.KOTLIN,
"kt" to Language.KOTLIN,
"kts" to Language.KOTLIN,
"rust" to Language.RUST,
"rs" to Language.RUST,
"sh" to Language.SHELL,
"bash" to Language.SHELL,
"shell" to Language.SHELL,
"zsh" to Language.SHELL,
"console" to Language.SHELL,
"python" to Language.PYTHON,
"py" to Language.PYTHON,
"javascript" to Language.JAVASCRIPT,
"js" to Language.JAVASCRIPT,
"jsx" to Language.JAVASCRIPT,
"typescript" to Language.TYPESCRIPT,
"ts" to Language.TYPESCRIPT,
"tsx" to Language.TYPESCRIPT,
"java" to Language.JAVA,
"c" to Language.C,
"h" to Language.C,
"cpp" to Language.CPP,
"c++" to Language.CPP,
"cc" to Language.CPP,
"hpp" to Language.CPP,
"csharp" to Language.CSHARP,
"cs" to Language.CSHARP,
"c#" to Language.CSHARP,
"go" to Language.GO,
"golang" to Language.GO,
"swift" to Language.SWIFT,
"dart" to Language.DART,
"ruby" to Language.RUBY,
"rb" to Language.RUBY,
"php" to Language.PHP,
"perl" to Language.PERL,
"pl" to Language.PERL,
"coffeescript" to Language.COFFEESCRIPT,
"coffee" to Language.COFFEESCRIPT,
"ron" to Language.RON,
"toml" to Language.TOML,
"fish" to Language.FISH,
"json" to Language.JSON,
"markdown" to Language.MARKDOWN,
"md" to Language.MARKDOWN,
)
/**
* Every fence in [parse], as the code and language [highlight] will be asked for. Walks the whole
* tree rather than the top level: a fence inside a list item or a quote is drawn the same way and
* costs the same to lex.
*/
fun fences(parse: State): List<Pair<String, Language?>> {
val success = parse as? State.Success ?: return emptyList()
val out = ArrayList<Pair<String, Language?>>()
fun walk(node: ASTNode) {
if (
node.type == MarkdownElementTypes.CODE_FENCE ||
node.type == MarkdownElementTypes.CODE_BLOCK
) {
fenceContent(success.content, node)?.let { if (it.second != null) out += it }
return
}
node.children.forEach(::walk)
}
walk(success.node)
return out
}
@@ -1,151 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Something a session can be asked to do to itself, rather than something to say to it.
*
* These are the two this app understands, and understanding them is what lets it show them: a
* suggestion while one is being typed, a name in the settings screen that sends one, and a bubble
* that stays up while the session is too busy to run it. Anything else beginning with "/" is passed
* through, because a dialect's own vocabulary grows without this list.
*/
data class SessionCommand(
/** With the slash, as it is typed and as it is sent. */
val name: String,
/** One line, in the suggestion list: what it does, not how. */
val summary: String,
/** What follows the name, named for the reader, or null when nothing does. */
val argument: String?,
) {
/** What to put in the box when this is picked: ready to send, or ready to be finished. */
fun typed(): String = if (argument == null) name else "$name "
}
val SESSION_COMMANDS =
listOf(
SessionCommand(
"/compact",
"Summarise the conversation so far and carry on from the summary",
null,
),
SessionCommand(
"/clear",
"Start fresh: drop the conversation from the session's context, keeping it on screen",
null,
),
SessionCommand("/rename", "Change what this session is called", "name"),
)
/**
* The commands worth offering for what has been typed so far.
*
* Only for a line that starts with a slash and has not yet become a whole command with an argument
* -- once there is something after "/rename ", the reader is writing the name and a list of
* commands underneath it is in the way.
*/
fun suggestedCommands(input: String): List<SessionCommand> {
if (!input.startsWith("/") || input.contains(' ')) return emptyList()
return SESSION_COMMANDS.filter { it.name.startsWith(input) }
}
/**
* The commands matching what is being typed, above the box they are being typed into.
*
* Above rather than over: a list that covers the transcript hides what the command is about, and
* the reader is usually looking at the thing they mean to act on.
*/
@Composable
fun CommandSuggestions(
commands: List<SessionCommand>,
onPick: (SessionCommand) -> Unit,
modifier: Modifier = Modifier,
) {
if (commands.isEmpty()) return
Card(modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Column(Modifier.padding(vertical = 4.dp)) {
commands.forEach { command ->
Row(
Modifier.fillMaxWidth()
.clickable { onPick(command) }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
// The command in the colour commands are, so the suggestion and the bubble
// it becomes are visibly the same thing.
if (command.argument == null) command.name
else "${command.name} <${command.argument}>",
style = MaterialTheme.typography.titleSmall,
color = commandColor,
)
Spacer(Modifier.width(12.dp))
Text(
command.summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/**
* A command, where the reader put it: at their end of the conversation.
*
* Blue rather than the colour of something they said, because they did not say it to the model --
* it is an instruction to the session, and the reply to it is the session changing rather than
* anything appearing here.
*
* [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes.
*/
@Composable
fun CommandBubble(text: String, waiting: Boolean = false) {
Box(Modifier.fillMaxWidth()) {
Card(
colors = CardDefaults.cardColors(containerColor = commandColor),
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
) {
Column(Modifier.padding(12.dp)) {
// Stated beside the fill rather than inherited: a semantic colour has to carry its
// own contrast, because the surface under it will not change to rescue it.
Text(text, color = MaterialTheme.colorScheme.inverseOnSurface)
if (waiting) {
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(12.dp).height(12.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
Spacer(Modifier.width(6.dp))
Text(
"waiting for this turn to end",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
}
}
}
}
}
}
@@ -1,62 +0,0 @@
package com.example.aiapp
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* The mark a compaction leaves in the transcript.
*
* A divider rather than something anybody said: everything above it is out of the session's context
* now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider],
* which a clear also uses, so the two marks cannot drift apart.
*
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it.
*/
@Composable
fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) {
TranscriptDivider(compactionSummary(item), commandColor, modifier)
}
/**
* What to say about a compaction: the two sizes, and nothing else.
*
* The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer
* to why the wait was worth it. When they were not reported this says only that a compaction
* happened, rather than filling in a plausible number.
*/
fun compactionSummary(item: TranscriptItem.CompactedNote): String {
val pre = item.preTokens
val post = item.postTokens
return if (pre != null && post != null) {
"Compacted • ${tokens(pre)}${tokens(post)} tok"
} else {
"Compacted"
}
}
/**
* A token count as a reader reads one.
*
* Shared with the status row rather than formatted at each: the divider and the row report the same
* quantity about the same moment, and one grouping its thousands while the other did not read as
* two different measurements.
*/
fun tokens(count: Long): String = "%,d".format(count)
/**
* What the working indicator says while a compaction is running.
*
* Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a
* compaction has begun and then says nothing until it has finished, so any bar or estimate here
* would be this screen's guess wearing a measurement's clothes.
*
* [seconds] is null when this device did not see the compaction start, which is what opening a
* session that is already compacting looks like. That case says only "compacting": a number counted
* from the moment the screen opened would be wrong in the direction that matters.
*/
fun compactingLabel(seconds: Long?): String =
when {
seconds == null -> "compacting"
seconds < 60 -> "compacting ${seconds}s"
else -> "compacting ${seconds / 60}m ${seconds % 60}s"
}
@@ -1,63 +0,0 @@
package com.example.aiapp
import android.content.Context
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* The last crash, kept so the debug button can hand it over.
*
* The alternative is asking somebody to reproduce a crash with the phone plugged into a computer
* and `logcat` running, which is the one thing nobody has set up at the moment it happens. This
* costs one file write on a process that is already dying, and it turns "it crashes when I open
* that chat" into the frame it crashed in.
*
* Kept until it is read rather than cleared on the next launch: the app restarts before anybody can
* ask about it, so a log that lives for one session is a log that is never read.
*/
private const val CRASH_FILE = "last-crash.txt"
/**
* How much of a stack is kept.
*
* This is pasted into a conversation, so it has a budget like any other output written for a
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing.
*/
private const val CRASH_LIMIT = 4000
/**
* Records uncaught exceptions, then lets the platform do what it was going to do.
*
* Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and
* ends the process, and an app that swallows that instead sits there in an unknown state.
*/
fun installCrashLog(context: Context) {
val app = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
runCatching { File(app.filesDir, CRASH_FILE).writeText(describe(thread, error)) }
previous?.uncaughtException(thread, error)
}
}
private fun describe(thread: Thread, error: Throwable): String {
val when_ = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
val stack = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
val kept =
if (stack.length <= CRASH_LIMIT) stack
else stack.take(CRASH_LIMIT) + "\n ... ${stack.length - CRASH_LIMIT} more characters"
return "$when_ on thread ${thread.name}\n$kept"
}
/** The last crash, or null if there has not been one since it was last read. */
fun lastCrash(context: Context): String? =
File(context.applicationContext.filesDir, CRASH_FILE).takeIf { it.exists() }?.readText()
/** Forgets the last crash, once somebody has taken a copy of it. */
fun clearCrash(context: Context) {
File(context.applicationContext.filesDir, CRASH_FILE).delete()
}
@@ -1,184 +0,0 @@
package com.example.aiapp
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/**
* Counters and timers for the work the transcript does, for the readout behind the debug button.
*
* Here because the emulator cannot answer the question this is for. Its own scroll sits at the same
* frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost
* is under the floor of what it can measure. Counts do not have that problem: how many times a row
* was composed, or a reply parsed, is the same number on any machine, and it is the number that
* says whether the work is proportional to what is on screen or to everything ever loaded.
*
* Always on rather than behind a build flag. What is measured is an atomic increment on paths that
* already allocate lists and parse markdown, and a counter that is only compiled into the build
* nobody is holding when it is slow is not an instrument.
*/
object DebugStats {
private val counts = ConcurrentHashMap<String, AtomicLong>()
private val nanos = ConcurrentHashMap<String, AtomicLong>()
private val worst = ConcurrentHashMap<String, AtomicLong>()
private fun at(map: ConcurrentHashMap<String, AtomicLong>, name: String) =
map.computeIfAbsent(name) { AtomicLong() }
fun count(name: String, by: Long = 1) {
at(counts, name).addAndGet(by)
}
/** Keeps [name] at the largest value it has been given, for a high-water mark. */
fun atLeast(name: String, value: Long) {
val slot = at(counts, name)
while (true) {
val had = slot.get()
if (value <= had || slot.compareAndSet(had, value)) break
}
}
/** Records one occurrence of [name] that took [elapsed] nanoseconds. */
fun record(name: String, elapsed: Long) {
count(name)
at(nanos, name).addAndGet(elapsed)
val slot = at(worst, name)
while (true) {
val had = slot.get()
if (elapsed <= had || slot.compareAndSet(had, elapsed)) break
}
}
fun <T> timed(name: String, body: () -> T): T {
val started = System.nanoTime()
try {
return body()
} finally {
record(name, System.nanoTime() - started)
}
}
fun reset() {
counts.clear()
nanos.clear()
worst.clear()
}
/** One line per counter: how many, how long in total, and the worst single one. */
fun lines(): List<String> =
counts.keys.sorted().map { name ->
val n = counts[name]?.get() ?: 0
val total = nanos[name]?.get() ?: 0
if (total == 0L) " $name: $n"
else
" $name: $n, ${ms(total)}ms total, ${ms(total / n.coerceAtLeast(1))}ms mean," +
" ${ms(worst[name]?.get() ?: 0)}ms worst"
}
/** How long everything named [name] took in total, or zero if it never happened. */
fun nanosOf(name: String): Long = nanos[name]?.get() ?: 0
private fun ms(nanos: Long) = "%.1f".format(nanos / 1_000_000.0)
}
/**
* How much of the frame's draw phase is this app's own work, and how much is not.
*
* The draw phase is where Compose's measurement lands as well as its recording -- the platform
* calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three
* different things is high. The transcript times its own measure, placement and recording, and this
* is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout,
* which grows with how many nodes are alive rather than how many are on screen.
*
* Per frame rather than in total, because the budget it has to fit in is per frame. The recordings
* are not themselves per-frame, so these are shares of an average frame.
*/
fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
if (frames == 0 || drawNanos == 0L) return emptyList()
val measure = DebugStats.nanosOf("measure: the whole transcript")
val place = DebugStats.nanosOf("place: the whole transcript")
// The rows and blocks record *inside* this one, so adding them too would count them twice.
val record = DebugStats.nanosOf("draw: the whole transcript")
val ours = measure + place + record
val rest = (drawNanos - ours).coerceAtLeast(0)
fun per(n: Long) = "%.2f".format(n / 1_000_000.0 / frames)
return listOf(
" draw phase ${per(drawNanos)}ms per frame, of which:",
" the transcript: ${per(ours)}ms" +
" (measure ${per(measure)}, place ${per(place)}, record ${per(record)})",
" everything else: ${per(rest)}ms" +
" (${if (drawNanos == 0L) "n/a" else "${rest * 100 / drawNanos}%"})",
)
}
/**
* Everything the debug button copies: what the device is, what the transcript is holding, where the
* frames went, and what the app did to produce them.
*
* Written for somebody to paste into a conversation, so it is plain text with the units on every
* number -- a report whose reader has to ask what the columns mean costs another round trip.
*/
fun debugReport(
device: String,
transcript: List<String>,
frames: List<String>,
accounting: List<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 {
appendLine("ai-app render report")
appendLine(device)
appendLine()
// First, because a crash outranks every timing below it and the reader should not have to
// scroll past two screens of counters to find out the app fell over.
if (crash != null) {
appendLine("last crash:")
crash.trimEnd().lines().forEach { appendLine(" $it") }
appendLine()
}
appendLine("transcript:")
transcript.forEach { appendLine(it) }
appendLine()
if (phaseFrames.isNotEmpty()) {
appendLine("per phase:")
phaseFrames.forEach { appendLine(it) }
appendLine()
}
appendLine("frames:")
frames.forEach { appendLine(it) }
appendLine()
if (accounting.isNotEmpty()) {
appendLine("where the draw phase went:")
accounting.forEach { appendLine(it) }
appendLine()
}
appendLine("work since this was last copied:")
val work = DebugStats.lines()
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. */
fun Context.copyToClipboard(label: String, text: String) {
getSystemService<ClipboardManager>()?.setPrimaryClip(ClipData.newPlainText(label, text))
}
@@ -1,90 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* A line across the transcript saying what left the session's context.
*
* Centred between two rules, because it is a divider rather than something anybody said. Two things
* produce one -- a compaction and a clear -- and they are drawn the same way on purpose: to a
* reader scrolling back, both mean "the session no longer has what is above this", and which of the
* two it was is said by the words and the colour.
*
* The rules take [color] too, so the whole divider reads as one mark of one kind.
*/
@Composable
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
HorizontalDivider(Modifier.weight(1f), color = color)
Text(text, style = MaterialTheme.typography.bodySmall, color = color)
HorizontalDivider(Modifier.weight(1f), color = color)
}
}
/**
* The mark a clear leaves.
*
* Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a
* compaction it summarises nothing and measures nothing. Everything above stays on screen and stays
* scrollable -- the reader can see that, which is why this does not say it.
*/
@Composable
fun ClearedRow(modifier: Modifier = Modifier) {
TranscriptDivider("Context cleared", clearedColor, modifier)
}
/**
* The mark running out of quota leaves.
*
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
* place: colour by consequence, so "there is nothing left to spend" is learned once.
*
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
* which is not a promise about when the session picks back up. A limit the session was told no
* reset time for says nothing about one -- that state has its own words rather than a plausible
* number.
*/
@Composable
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
}
/**
* What the row says. Split out so the wording is testable without a screen, since the two states it
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
* that reads the same when it goes wrong.
*
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
*/
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
val at = resetsAt?.let {
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone)
.format(Instant.ofEpochSecond(it.toLong()))
} catch (_: Exception) {
null
}
}
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
}
@@ -1,33 +0,0 @@
package com.example.aiapp
import android.content.Context
import androidx.core.content.edit
private const val DRAFTS = "session-drafts"
/**
* A message typed into a session and not sent yet.
*
* On this device rather than on the backend, which is where this app otherwise keeps state so that
* every device sees it. A draft is the case that rule is not about: it is the contents of a text
* box on the phone somebody is holding, and half a sentence surfacing on another device would be a
* surprise. What has been *sent* is the server's.
*
* Kept per session id: one shared box would hand a message meant for one session to whichever was
* opened next.
*/
fun loadDraft(context: Context, sessionId: String): String =
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty()
/**
* Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep.
*
* The path out is emptying the box, which is what sending does. A session *deleted* while it held a
* draft does leave its key behind: pruning those means a pass over the live session list, and the
* residue is a few bytes per session ever abandoned mid-sentence.
*/
fun saveDraft(context: Context, sessionId: String, text: String) {
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit {
if (text.isEmpty()) remove(sessionId) else putString(sessionId, text)
}
}
@@ -1,37 +0,0 @@
package com.example.aiapp
/**
* A span of milliseconds, written the way somebody reads it.
*
* 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.
*
* Sub-second precision is dropped past a minute: nothing that takes days is measured in
* milliseconds.
*/
fun formatMillis(ms: Long): String {
if (ms < 0) return "-" + formatMillis(-ms)
if (ms < 1000) return "${ms}ms"
if (ms < 60_000) {
val tenths = (ms + 50) / 100
val whole = tenths / 10
val rest = tenths % 10
return if (rest == 0L) "${whole}s" else "$whole.${rest}s"
}
val seconds = ms / 1000
val parts =
listOf(
"d" to seconds / 86_400,
"h" to seconds / 3600 % 24,
"m" to seconds / 60 % 60,
"s" to seconds % 60,
)
return parts.filter { it.second > 0 }.joinToString(" ") { "${it.second}${it.first}" }
}
/** [text] as a span when it is a whole number of milliseconds, and unchanged when it is not. */
fun formatMillisText(text: String): String =
text.trim().toLongOrNull()?.let { formatMillis(it) } ?: text
@@ -1,44 +0,0 @@
package com.example.aiapp
/**
* The frame name the server uses to say a cursor was too far behind to continue from. Must match
* `send_backlog` in the backend's routes.rs.
*/
private const val RESET_EVENT = "reset"
/**
* The SSE half of the API: one long-lived GET per open session screen, replaying the transcript
* after a cursor and then following it live.
*
* The connection and its framing belong to [Sse]; what stays here is what this stream's frames
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
* saw as the new cursor.
*/
class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
private val stream = Sse(settings)
fun close() = stream.close()
/**
* Streams events after [after] into [onEvent] until the stream drops.
*
* [onReset] fires when the server answers that the cursor is too far behind to continue from:
* everything already displayed is stale and the events that follow are a fresh window, so the
* caller drops what it holds and rebuilds. It arrives before those events, so a caller that
* clears on it stays in order.
*/
fun run(
after: Long,
onOpen: () -> Unit,
onReset: () -> Unit,
// The frame's own text as well as the event parsed from it: the transcript cache stores the
// one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit,
) {
stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name.
if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
}
}
}
@@ -1,317 +0,0 @@
package com.example.aiapp
import org.json.JSONObject
// The common event model, mirrored from server/src/session/driver.rs -- the app renders purely from
// this stream (replayed from the transcript by cursor, then live), so there is no separate "load
// history" shape to keep in sync with it.
/** One transcript line: the event plus its resume cursor and time. */
data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
/**
* One choice offered in answer to a question.
*
* More than a label because the reader is deciding rather than confirming. Both are absent on a
* permission, whose Allow and Deny mean exactly what they say.
*/
data class QuestionOption(val label: String, val description: String?, val preview: String?)
sealed class SessionEvent {
data class UserMessage(
val text: String,
/**
* The [MessageQueued] this resolves, or null when it never waited.
*
* Matched on rather than the text, because the same message sent twice is two waiting
* bubbles and clearing whichever one matched first would leave the wrong one on screen.
*/
val id: String?,
/**
* What was attached to it, by the ref the files route serves: images, and any file, told
* apart by [isImageRef].
*
* On the message rather than beside it: these arrived as separate image events until
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
* it, and left this app deciding from adjacency which message an image went with.
*/
val attachments: List<String>,
) : SessionEvent()
/**
* A message the server has accepted and the session has not read yet.
*
* From the server, not from this app's memory of what it sent. The pending bubble used to be
* screen state, so leaving the session drew nothing waiting while the message was still queued
* -- and nothing waiting is what "there is nothing" looks like.
*
* Resolved by the [UserMessage] carrying the same id.
*/
data class MessageQueued(val id: String, val text: String, val attachments: List<String>) :
SessionEvent()
/**
* A queued message taken back before the session read it.
*
* Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects
* replays both, and without this one it would put back a bubble for a message that is never
* coming.
*/
data class MessageDropped(val id: String) : SessionEvent()
data class AssistantText(val delta: 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 ToolEnd(val id: String, val output: String) : SessionEvent()
data class Image(
val ref: String,
/** The tool call whose result carried it, or null for a person's own attachment. */
val about: String?,
) : SessionEvent()
data class Question(
val id: String,
val prompt: String,
/** A few words naming what the question is about, when the asker offered one. */
val header: String?,
val options: List<QuestionOption>,
/** Whether several options may be chosen at once. */
val multiSelect: Boolean,
/** The tool call this is permission for, or null when it is not about one. */
val about: String?,
) : SessionEvent()
/** Everything chosen for one question, in the order it was offered. */
data class Answered(val id: String, val answers: List<String>) : SessionEvent()
/**
* A message another agent sent this session.
*
* Not a [UserMessage]: nobody holding the phone said it, and drawing it in their voice would
* claim they had. It is also the explanation for a session that starts working on something
* this device never asked for.
*/
data class PeerMessage(
val from: String,
val text: String,
/**
* Where the turn this started begins, when the server could say.
*
* The live Claude Code path only learns a turn was somebody else's when the turn ends, so
* the event arrives below everything it caused; this is what puts it back above it. Null
* for a message read out of a session file, and for one that started no turn.
*/
val turnStart: Long? = null,
) : SessionEvent()
/**
* 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.
*/
data class CommandQueued(val id: String, val text: String) : SessionEvent()
/** The same command, handed to the session. */
data class CommandSent(val id: String, val text: String) : SessionEvent()
data class Status(val state: String) : SessionEvent()
/**
* What the session is set to, as the session itself reports it.
*
* Either field alone: the two are confirmed separately. Asking for a change is not having one,
* so this -- not the request -- is what the pickers show.
*/
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
/**
* What a turn cost, and how much the model was holding when it ended.
*
* [context] is prompt plus both cache figures. Carried on the event rather than summed by the
* reader, because it is not a sum: a conversation's context drops at a compaction and a clear,
* 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.
*/
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
/**
* A compaction that finished, and how much context it recovered.
*
* The counts are nullable because the server sends them only when it was told them: a zero here
* would read as "recovered nothing" and a made-up number would read as a measurement.
*/
data class Compacted(
val preTokens: Long?,
val postTokens: Long?,
/** What asked for it, in the CLI's own word; `auto` is the one worth naming. */
val trigger: String?,
) : SessionEvent()
/**
* The conversation was cleared. Everything above this is still here to read and is no longer in
* the session's context. An object rather than a class because what it means is entirely its
* position in the transcript.
*/
data object Cleared : SessionEvent()
/**
* The session stopped because its account's usage limit was reached.
*
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
* limit reached|1788546972`, which is not readable by the person it is shown to.
*
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
*/
data class LimitReached(val resetsAt: Double?) : SessionEvent()
data class Error(val message: String) : SessionEvent()
/**
* An event type this app build doesn't know -- a newer server. Kept rather than thrown so one
* new event kind degrades to a placeholder row instead of killing the stream.
*/
data class Unknown(val type: String) : SessionEvent()
}
/**
* A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since
* the server omits the field rather than sending an empty list.
*/
private fun JSONObject.stringList(name: String): List<String> {
val array = optJSONArray(name) ?: return emptyList()
return (0 until array.length()).map { array.getString(it) }
}
fun parseSeqEvent(json: String): SeqEvent {
val body = JSONObject(json)
val event =
when (val type = body.getString("type")) {
"userMessage" ->
SessionEvent.UserMessage(
body.getString("text"),
body.optString("id").ifEmpty { null },
body.stringList("attachments"),
)
"messageQueued" ->
SessionEvent.MessageQueued(
body.getString("id"),
body.getString("text"),
body.stringList("attachments"),
)
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"toolStart" ->
SessionEvent.ToolStart(
id = body.getString("id"),
tool = body.getString("tool"),
// Kept as raw JSON text: the input shape is the tool's own business, and the UI
// only ever shows it verbatim.
input = body.get("input").toString(),
)
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
"toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output"))
"image" ->
SessionEvent.Image(
ref = body.getString("ref"),
about = body.optString("about").ifEmpty { null },
)
"question" ->
SessionEvent.Question(
id = body.getString("id"),
prompt = body.getString("prompt"),
header = body.optString("header").ifEmpty { null },
options =
body.getJSONArray("options").let { options ->
(0 until options.length()).map { at ->
val option = options.getJSONObject(at)
QuestionOption(
label = option.getString("label"),
description = option.optString("description").ifEmpty { null },
preview = option.optString("preview").ifEmpty { null },
)
}
},
multiSelect = body.optBoolean("multiSelect", false),
about = body.optString("about").ifEmpty { null },
)
"answered" ->
SessionEvent.Answered(
body.getString("id"),
body.getJSONArray("answers").let { answers ->
(0 until answers.length()).map { answers.getString(it) }
},
)
"peerMessage" ->
SessionEvent.PeerMessage(
body.getString("from"),
body.getString("text"),
if (body.has("turnStart")) body.getLong("turnStart") else null,
)
"commandQueued" ->
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
"status" -> SessionEvent.Status(body.getString("state"))
"settings" ->
SessionEvent.Settings(
model = body.optString("model").ifEmpty { null },
permissionMode = body.optString("permissionMode").ifEmpty { null },
)
"usageDelta" ->
SessionEvent.UsageDelta(
body.getLong("tokens"),
if (body.has("context")) body.getLong("context") else null,
)
"compacted" ->
SessionEvent.Compacted(
preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null,
postTokens = if (body.has("postTokens")) body.getLong("postTokens") else null,
trigger = body.optString("trigger").ifEmpty { null },
)
"cleared" -> SessionEvent.Cleared
"limitReached" ->
SessionEvent.LimitReached(
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
)
"error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type)
}
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
}
/**
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
*
* One predicate because two readers have to agree on the list: the session screen's working
* indicator, and the fold's decision that the newest reply is finished. Two copies would drift the
* first time the server grows a state, and the drift would be a reply that never splits or one
* split mid-stream.
*/
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
/**
* The context after [event], given what it was before.
*
* The same rule the server folds with, because the screen has to keep up between page loads: the
* summary it opened with is a measurement from before this stream started.
*
* The two that lower it are the point. A clear takes the conversation away and a compaction
* replaces it with a summary, so a figure measured before either stopped being true at that moment
* -- and carrying it forward is how a session that had just been cleared went on reporting the
* context it no longer had.
*
* Null is "we don't know", which each of them can reach.
*/
fun contextAfter(current: Long?, event: SessionEvent): Long? =
when (event) {
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn
// -- which every context figure is -- rather than unknown.
is SessionEvent.UsageDelta -> event.context ?: current
is SessionEvent.Compacted -> event.postTokens
is SessionEvent.Cleared -> null
else -> current
}
@@ -1,162 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
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.graphics.SolidColor
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
/**
* The largest file this app will open in the editor, in bytes.
*
* Measured on the emulator 2026-09-04, in a debug build, on generated Rust:
*
* | file | lines | scan per keystroke | worst frame record | typing |
* |--------|--------|--------------------|--------------------|-------------------|
* | 32 kB | 917 | 10ms | 183ms | sluggish, correct |
* | 128 kB | 3,633 | 40ms | 2,027ms | characters lost |
* | 1 MB | 28,660 | -- | -- | stops responding |
*
* The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file
* costs 40ms a keystroke, which is survivable, while laying the same text out in one
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- what
* EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single
* text field pays that cost. A line-by-line editor is the way past this.
*
* 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays
* the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not.
*/
const val EDIT_LIMIT = 32L * 1024
/**
* The same file, editable, in the same face and colours it was being read in.
*
* `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement
* that colours a field's own text rather than replacing the field with something that only looks
* like one: the transformation returns the text unchanged and the scanner's spans as styles, so
* [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook
* for styles at all.
*
* The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is
* sized against.
*
* The gutter is one `Text` of `1\n2\n…` beside the field rather than a number per row, because
* there are no rows here -- the field is one text object. It lines up for the same reason the
* viewer's does: nothing wraps, so a logical line is a visual line.
*/
@Composable
fun FileEditor(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
language: Language?,
modifier: Modifier = Modifier,
) {
val style = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface)
val scroll = rememberScrollState()
val count = value.text.removeSuffix("\n").count { it == '\n' } + 1
val gutter = gutterWidth(count, style)
val numbers = remember(count) { (1..count).joinToString("\n") }
val transformation =
remember(language) {
VisualTransformation { text ->
TransformedText(highlight(text.text, language), OffsetMapping.Identity)
}
}
Row(verticalAlignment = Alignment.Top, modifier = modifier.fillMaxWidth()) {
Text(
numbers,
style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
softWrap = false,
modifier = Modifier.width(gutter),
)
// The same gap the viewer puts between its numbers and its code, so switching between
// reading and editing does not move the text sideways under the reader.
Spacer(Modifier.width(GUTTER_GAP))
Box(Modifier.horizontalScroll(scroll)) {
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = style,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
visualTransformation = transformation,
)
}
}
}
/**
* What to do about a file that changed on the machine while it was open here.
*
* Three ways out rather than one, and each says what it costs, because there is no answer this app
* can pick on somebody's behalf: an agent editing the same file is the ordinary case here, and both
* versions are somebody's work.
*/
@Composable
fun ConflictDialog(
message: String,
busy: Boolean,
onOverwrite: () -> Unit,
onReload: () -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
// The server's own sentence as the title, rather than a heading of this app's above it
// saying the same thing twice: there is one statement of what happened and it comes from
// the side that found out.
title = { Text(message.replaceFirstChar { it.uppercase() }) },
text = {
Text(
"Overwrite keeps what you typed and loses the other change. " +
"Reload keeps the other change and loses what you typed. " +
"Cancel leaves both alone and keeps you here."
)
},
confirmButton = {
TextButton(onClick = onOverwrite, enabled = !busy) {
Text(if (busy) "Saving..." else "Overwrite")
}
},
dismissButton = {
Row {
TextButton(onClick = onReload, enabled = !busy) { Text("Reload") }
TextButton(onClick = onCancel, enabled = !busy) { Text("Cancel") }
}
},
)
}
/** Leaving an editor with edits in it, which is the one way to lose them by accident. */
@Composable
fun UnsavedDialog(onDiscard: () -> Unit, onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Leave without saving?") },
text = {
Text(
"The edits you have made here will be lost. They have not been written to the machine."
)
},
confirmButton = { TextButton(onClick = onDiscard) { Text("Discard") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Keep editing") } },
)
}
@@ -1,122 +0,0 @@
package com.example.aiapp
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
/**
* A file split into lines, with the highlighter's colours already worked out for each one.
*
* The pure half of the viewer, so it has a JVM unit test and so [of] can run off the main thread:
* scanning a megabyte is work, and doing it inside a composable would do it on the drawing thread
* and again on every recomposition.
*
* Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text
* layout is linear in the text. That means each row needs *its* colours, and the scanner answers in
* offsets into the whole file -- so the spans are bucketed here, once, in one pass.
*/
class FileLines
private constructor(
/** The text of each line, without its newline. */
val lines: List<String>,
/** Per line, the spans that fall in it, with offsets relative to that line's start. */
private val spans: List<List<Span>>,
/**
* The longest line, in character columns -- what the viewer sizes every row to.
*
* Every row has to be the *same* width or they scroll sideways by different amounts; see
* [FileViewer]. Columns rather than measured pixels because the face is monospace, so one
* number and one character's advance give the width of the widest line without measuring twenty
* thousand strings.
*/
val columns: Int,
) {
val size: Int
get() = lines.size
/**
* One line, coloured. Built when the row is composed rather than up front: a file has far more
* lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the
* lazy list exists to avoid.
*/
fun line(index: Int): AnnotatedString {
val text = lines[index]
val here = spans[index]
if (here.isEmpty()) return AnnotatedString(text)
val palette = catppuccinSyntax()
return buildAnnotatedString {
append(text)
here.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
}
}
companion object {
/**
* [text] scanned as [language] and cut into lines.
*
* Exactly one trailing newline is dropped before splitting, so a file that ends the way
* text files are supposed to end has the number of lines its author would count -- `wc -l`
* agrees. Without that, every well-formed file gained a phantom empty last line. An empty
* file is one empty line numbered 1, which is what it is.
*/
fun of(text: String, language: Language?): FileLines =
// Timed, and always, for the reason everything else here is: the cost of opening a
// large file is the number that decides whether the server's size limit is right, and
// an instrument that is only in the build nobody is running answers nothing.
DebugStats.timed("file scanned and cut into lines") {
val body = text.removeSuffix("\n")
val lines = body.split('\n')
val scanned = if (language == null) emptyList() else spansOf(body, language)
FileLines(lines, bucket(lines, scanned), lines.maxOf(::columnsOf))
}
/**
* How many columns a line occupies.
*
* A tab counts as eight rather than one, and deliberately upwards: this decides how far the
* viewer can scroll, and over-estimating leaves a little empty space past the longest line
* where under-estimating makes the end of that line unreachable.
*/
private fun columnsOf(line: String): Int {
var count = 0
for (character in line) count += if (character == '\t') 8 else 1
return count
}
/**
* The scanner's spans, in file offsets, as spans per line in line offsets.
*
* One walk down both lists, which is what the scanner's guarantee buys: its spans come out
* ordered, non-overlapping and inside the text. A span crossing a line break is cut at each
* break and appears in each line it covers, because a row is drawn on its own and cannot
* inherit a colour from the row above.
*/
private fun bucket(lines: List<String>, spans: List<Span>): List<List<Span>> {
val out = ArrayList<List<Span>>(lines.size)
var lineStart = 0
var next = 0
for (line in lines) {
val lineEnd = lineStart + line.length
var here: ArrayList<Span>? = null
// Spans that ended before this line begins are behind the walk for good.
while (next < spans.size && spans[next].end <= lineStart) next++
var at = next
while (at < spans.size && spans[at].start < lineEnd) {
val span = spans[at]
val start = maxOf(span.start, lineStart) - lineStart
val end = minOf(span.end, lineEnd) - lineStart
if (end > start) {
(here ?: ArrayList<Span>().also { here = it }).add(
Span(start, end, span.kind)
)
}
at++
}
out.add(here ?: emptyList())
// The newline itself, which is in the text and not in any line.
lineStart = lineEnd + 1
}
return out
}
}
}
@@ -1,242 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */
@Composable
fun codeStyle(): TextStyle =
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
/**
* [content] scanned off the main thread, then drawn.
*
* Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660
* lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first
* written, that is 460ms of frozen screen at the size the server is willing to send -- long enough
* that the accessibility tree cannot be read, which is what "the app has stopped" looks like.
*
* Keyed on the text and the language, so re-reading the same file does not rescan it.
*/
@Composable
fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) {
var lines by remember(content, language) { mutableStateOf<FileLines?>(null) }
LaunchedEffect(content, language) {
lines = withContext(Dispatchers.Default) { FileLines.of(content, language) }
}
when (val ready = lines) {
null -> CircularProgressIndicator(Modifier.padding(8.dp))
else -> FileViewer(ready, modifier)
}
}
/**
* A file, one line per row, coloured by the same scanner that colours a reply's code fences.
*
* A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is
* that each row needs its own colours, which is what [FileLines] works out once and off this
* thread.
*
* Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a
* block and a long line does not silently become three -- which would put the gutter's numbers
* against the wrong text.
*
* **Every row is given the same content width**, and that is what makes the shared scroll state
* behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset
* into *its own* range -- `content width - viewport` -- so with rows of their natural widths a
* short line's range is zero and it never moves while a long one beside it does. Each row also
* writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever
* row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on
* 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range
* looks like.
*
* The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box
* around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise,
* so only the line under the finger stretched. Only possible because every row now has the same
* range.
*
* The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the
* numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width
* is measured from the digit count of the line count in the style it is drawn in.
*
* Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and
* copying it gives the code rather than the code with a number in front of every line.
*/
@Composable
fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
val style = codeStyle()
val scroll = rememberScrollState()
val overscroll = rememberOverscrollEffect()
val rows = rememberLazyListState()
val gutter = gutterWidth(lines.size, style)
val content = contentWidth(lines.columns, style)
Box(modifier.fillMaxSize()) {
// One container around the whole file rather than one per line, so a selection can run
// across lines -- the same arrangement the transcript uses.
SelectionContainer {
// The stretch is drawn here, once, over everything this box holds; the rows below only
// feed it. `clipToBounds` because a stretch draws outside the box it came from.
Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) {
LazyColumn(state = rows, modifier = Modifier.fillMaxSize()) {
items(lines.size) { index ->
Row(verticalAlignment = Alignment.Top) {
// Where the numbers go, drawn from outside this box.
Spacer(Modifier.width(gutter + GUTTER_GAP))
Text(
lines.line(index),
style = style,
softWrap = false,
// The scroll outside the width: the scrolling node's viewport is
// what the row has room for, and its content is the whole file's
// widest line. The shared effect is given to every row and rendered
// by none of them -- see the box above.
modifier =
Modifier.horizontalScroll(scroll, overscroll).width(content),
)
}
}
}
}
}
LineGutter(rows, gutter, style)
}
}
/**
* The line numbers, drawn beside the file rather than in it.
*
* They have to be outside the box the stretch is rendered on, or they bend with the text; and they
* have to stay exactly level with the lines they number. Those two pull in opposite directions.
*
* A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come
* from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during
* measurement, so this composes from the answer the list has just produced rather than one it read
* a frame ago. A `Column` translated by the scroll position could not: the translation would be
* current while the set of numbers was a composition behind, so during a fling the numbers would
* slide against their lines.
*
* The list is measured before this is -- they are siblings in a `Box` and it is declared first.
*
* `onSurfaceVariant`, because a number is not part of the file. The background is painted because
* the stretch can carry the text sideways under this column, and a digit with a smear of code
* behind it reads as a rendering fault.
*/
@Composable
private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
val colour = MaterialTheme.colorScheme.onSurfaceVariant
val surface = rawSurface
SubcomposeLayout(Modifier.fillMaxHeight().width(width).background(surface).clipToBounds()) {
constraints ->
val visible = rows.layoutInfo.visibleItemsInfo
val numbers = visible.map { item ->
subcompose(item.index) {
Text(
(item.index + 1).toString(),
style = style,
color = colour,
textAlign = TextAlign.End,
maxLines = 1,
)
}
.first()
.measure(Constraints.fixedWidth(constraints.maxWidth))
}
layout(constraints.maxWidth, constraints.maxHeight) {
numbers.forEachIndexed { index, number -> number.place(0, visible[index].offset) }
}
}
}
/**
* How wide the widest line number is, measured rather than guessed.
*
* `9` repeated, because digits in a monospace face are all one width -- what matters is how many
* there are. Measuring in the style the numbers are drawn in is what makes this survive a font
* size, a density or a display scale nobody here chose.
*/
@Composable
fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val digits = maxOf(1, lineCount.toString().length)
return remember(digits, style, density) {
with(density) {
measurer.measure(AnnotatedString("9".repeat(digits)), style).size.width.toDp()
}
}
}
/**
* How wide to make every row: the widest line in the file, in this style.
*
* One character measured rather than the line itself, because the face is monospace and measuring
* the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already
* has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of
* rounding.
*
* Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary
* one: a minified file is a single line of a hundred thousand characters, and laying that out as
* one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be
* reached, which is the tolerable half of that trade.
*/
@Composable
private fun contentWidth(columns: Int, style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(columns, style, density) {
val advance = measurer.measure(AnnotatedString("0".repeat(64)), style).size.width / 64f
with(density) { (columns * advance).coerceAtMost(MAX_CONTENT_PX).toDp() }
}
}
/**
* The widest a row may be laid out, in pixels. Well under what `Constraints` can carry, and far
* past any line anybody reads.
*/
private const val MAX_CONTENT_PX = 100_000f
/**
* The space between the numbers and the code. A gap, not an alignment: the two are already aligned
* by the row, and this is only so the digits and the first character are not touching.
*/
val GUTTER_GAP = 8.dp
@@ -1,688 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Which machine's files to show, and where to start.
*
* 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 setups tab -- one more
* caller rather than any new code here.
*/
data class FilesTarget(val setup: String, val setupName: String, val start: String)
/** Where the explorer is: in a directory, or in one file. */
private sealed class Spot(val path: String) {
class Dir(path: String) : Spot(path)
class Doc(path: String) : Spot(path)
}
/**
* 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
* flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from
* where it opened.
*
* 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
* created in.
*/
@Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
val scope = rememberCoroutineScope()
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below,
// 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 dirty by remember { mutableStateOf(false) }
var askUnsaved by remember { mutableStateOf(false) }
val here = stack.last()
fun go(spot: Spot) {
editing = false
dirty = false
stack = stack + spot
}
fun back() {
when {
editing && dirty -> askUnsaved = true
editing -> editing = false
stack.size > 1 -> {
stack = stack.dropLast(1)
editing = false
dirty = false
}
else -> onClose()
}
}
suspend fun load(path: String, again: Boolean) {
if (!again && listings[path] is LoadState.Loaded) return
listings[path] = LoadState.Loading
listings[path] =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchDir(settings, target.setup, path))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
BackHandler(onBack = ::back)
Box(
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
// The session under this deliberately takes no keyboard inset, so the explorer adds its
// own -- otherwise the editor types under the keyboard.
.imePadding()
) {
Column(Modifier.fillMaxSize()) {
when (val spot = here) {
is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading
// The resolved path once there is one: a directory opened as `~` is called what
// it turned out to be, not what it was asked for.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
FilesHeader(
title = baseName(at),
path = at,
machine = target.setupName,
onBack = ::back,
) {
GlyphButton(
REFRESH_GLYPH,
"Refresh this directory",
{ scope.launch { load(spot.path, again = true) } },
enabled = state !is LoadState.Loading,
)
GlyphButton(
PLUS_GLYPH,
"Create here",
{ creating = true },
enabled = state is LoadState.Loaded,
)
}
LaunchedEffect(spot.path) { load(spot.path, again = false) }
DirectoryBody(state, onOpen = ::go)
}
is Spot.Doc ->
DocPane(
settings = settings,
target = target,
path = spot.path,
name = baseName(spot.path),
editing = editing,
onEditing = { editing = it },
onDirty = { dirty = it },
onBack = ::back,
)
}
}
}
if (askUnsaved) {
UnsavedDialog(
onDiscard = {
askUnsaved = false
editing = false
dirty = false
},
onCancel = { askUnsaved = false },
)
}
val dir = here as? Spot.Dir
val listing = (listings[dir?.path] as? LoadState.Loaded)?.value
if (creating && dir != null && listing != null) {
CreateDialog(
settings = settings,
setup = target.setup,
directory = listing.path,
onDismiss = { creating = false },
onCreated = { path, isDirectory ->
creating = false
scope.launch {
// The directory it was created in is the one thing that changed, so that is
// what gets asked again -- not the whole stack.
load(dir.path, again = true)
// A new file has nothing to look at, so it opens where it can be filled in.
if (!isDirectory) {
go(Spot.Doc(path))
editing = true
}
}
},
)
}
}
/**
* The row every view in here has at the top: back, what this is, and what acts on it.
*
* The path is truncated in the middle when it will not fit, because both ends carry something the
* reader needs -- the machine and the top of the tree at one end, the file at the other -- and it
* is the longest paths, the ones being read most closely, that get cut.
*/
@Composable
private fun FilesHeader(
title: String,
path: String,
machine: String,
onBack: () -> Unit,
actions: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
Text(
"$machine · $path",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
Row { actions() }
}
}
/**
* What is in a directory.
*
* A listing that failed says why, in the machine's own words, where the rows would be -- never an
* empty list, which is what "there is nothing here" looks like and is the one wrong answer that
* looks like a right one.
*/
@Composable
private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
when (state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
is LoadState.Error ->
Text(
state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp),
)
is LoadState.Loaded -> {
val listing = state.value
val sorted = remember(listing) { sortForDisplay(listing.entries) }
LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
parentOf(listing.path)?.let { parent ->
item("..") {
EntryRow(
glyph = FOLDER_GLYPH,
name = "..",
trailing = null,
onClick = { onOpen(Spot.Dir(parent)) },
)
}
}
if (sorted.isEmpty()) {
item("empty") {
Text(
"Nothing here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
uniqueItems(sorted, key = { it.name }) { entry ->
val path = join(listing.path, entry.name)
EntryRow(
glyph = if (entry.isDirectory) FOLDER_GLYPH else FILE_GLYPH,
name = entry.name,
trailing = trailingOf(entry),
onClick = {
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
},
)
}
}
}
}
}
/**
* What a row says after the name, or nothing.
*
* A symlink says so instead of giving a size, because the size a listing reports for one is the
* length of the path it points at -- a number that looks exactly like a file size and is about
* something else. `other` covers a fifo, a device, and a link whose target is gone: the row still
* appears, because a directory that hid what it held would be lying about being empty.
*/
private fun trailingOf(entry: DirEntry): String? =
when {
entry.link -> "link"
entry.isDirectory -> null
entry.kind == "file" -> humanSize(entry.size) ?: "0 B"
else -> "other"
}
@Composable
private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Glyph(glyph, colour = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(12.dp))
Text(
name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.weight(1f),
)
trailing?.let {
Spacer(Modifier.width(8.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* One file: read, and edited behind the pencil.
*
* Its own composable so that everything about one file -- what came back, what has been typed, and
* whether a save is out -- is remembered under that file's path and thrown away when the reader
* moves to another. What is *not* here is edit mode itself: back has to know about it.
*/
@Composable
private fun ColumnScope.DocPane(
settings: ServerSettings,
target: FilesTarget,
path: String,
name: String,
editing: Boolean,
onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var state by remember(path) { mutableStateOf<LoadState<FileContent>>(LoadState.Loading) }
var draft by remember(path) { mutableStateOf(TextFieldValue()) }
var saving by remember(path) { mutableStateOf(false) }
var saveError by remember(path) { mutableStateOf<String?>(null) }
var conflict by remember(path) { mutableStateOf<String?>(null) }
// The editor's own vertical scroll, hoisted so the gutter and the text move together: they are
// two composables in one row, and a scroll inside either would leave the other behind.
val editScroll = rememberScrollState()
val language = remember(name) { fileLanguage(name) }
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text
// Readable but not editable: see [EDIT_LIMIT]. The size is the one the machine reported, so
// this is decided before anything is typed rather than discovered by a keyboard that stops
// answering.
val editable = loaded != null && loaded.size <= EDIT_LIMIT
suspend fun fetch() {
state = LoadState.Loading
state =
try {
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
if (got is FileContent.Text) draft = TextFieldValue(got.content)
LoadState.Loaded(got)
} catch (e: ApiException) {
LoadState.failed(e)
}
onDirty(false)
}
LaunchedEffect(path) { fetch() }
val changed = loaded != null && draft.text != loaded.content
LaunchedEffect(changed) { onDirty(changed) }
/** Writes the draft back, [against] being the digest it is allowed to replace. */
fun save(against: String) {
if (saving) return
saving = true
saveError = null
scope.launch {
try {
val written =
withContext(Dispatchers.IO) {
writeFile(settings, target.setup, path, draft.text, against)
}
state =
LoadState.Loaded(
FileContent.Text(
path,
written.size,
written.modified,
written.sha256,
draft.text,
)
)
conflict = null
onDirty(false)
onEditing(false)
} catch (e: ApiException) {
// The one refusal that is a question rather than a message: somebody else's edit is
// on the machine, and which of the two survives is not this app's to decide.
if (e.status == 409) conflict = e.message ?: "It changed on the machine."
else saveError = e.message
} finally {
saving = false
}
}
}
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
if (editing) {
if (saving) {
GlyphSpinner("Saving")
} else {
GlyphButton(
SAVE_GLYPH,
"Save",
{ loaded?.let { save(it.sha256) } },
// Disabled rather than hidden while there is nothing to write: a button that
// comes and goes makes its own absence the signal.
enabled = changed,
)
}
} else {
GlyphButton(
REFRESH_GLYPH,
"Read this file again",
{ scope.launch { fetch() } },
enabled = state !is LoadState.Loading,
)
GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable)
}
}
saveError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
// Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it
// is disabled -- and a reader who cannot edit a file they can plainly read will otherwise
// conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button
// never gets.
if (loaded != null && !editable) {
Text(
"Too big to edit here (${humanSize(loaded.size)}; the limit is " +
"${humanSize(EDIT_LIMIT)}). A text field this large stops answering the keyboard.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
is LoadState.Error ->
Text(
current.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
)
is LoadState.Loaded ->
when (val file = current.value) {
is FileContent.Text ->
if (editing) {
FileEditor(
draft,
{ draft = it },
language,
Modifier.verticalScroll(editScroll),
)
} else {
ScannedFile(file.content, language)
}
// Said in words, with the measurement that makes it make sense. Neither of
// these is an empty file and neither is an error, so neither may look like one.
is FileContent.Binary ->
Note(
"This is not text (${humanSize(file.size) ?: "0 B"}), so there is nothing to show."
)
is FileContent.TooBig ->
Note(
"This file is ${humanSize(file.size)}, which is more than the server will " +
"send. Nothing was read, so nothing here is a sample of it."
)
}
}
}
conflict?.let { message ->
ConflictDialog(
message = message,
busy = saving,
onOverwrite = {
// Re-read only to learn what it hashes to *now*, which is the digest an overwrite
// has to be allowed against. The content is deliberately thrown away: overwriting
// is the choice to lose it.
scope.launch {
val fresh =
try {
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
} catch (e: ApiException) {
saveError = e.message
conflict = null
return@launch
}
if (fresh is FileContent.Text) save(fresh.sha256)
else {
saveError =
"It is no longer a text file, so this app will not write over it."
conflict = null
}
}
},
onReload = {
conflict = null
scope.launch { fetch() }
},
onCancel = { conflict = null },
)
}
}
/** A sentence where the file's content would be, for the two states that have no content. */
@Composable
private fun Note(text: String) {
Text(
text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(8.dp),
)
}
/**
* Naming one thing in the directory that is open.
*
* A name and a switch, not a name and a body: the editor is where content is typed, and a modal
* with a text area in it is a second editor to keep in step with the first. A created file opens
* straight into edit mode, because an empty file is not something to look at.
*/
@Composable
private fun CreateDialog(
settings: ServerSettings,
setup: String,
directory: String,
onDismiss: () -> Unit,
onCreated: (String, Boolean) -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var isDirectory by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
fun create() {
val chosen = name.trim()
if (busy || chosen.isEmpty()) return
busy = true
error = null
val path = join(directory, chosen)
scope.launch {
try {
withContext(Dispatchers.IO) {
if (isDirectory) createDir(settings, setup, path)
else createFile(settings, setup, path)
}
onCreated(path, isDirectory)
} catch (e: ApiException) {
// Beside the button that caused it: this dialog is the only thing on screen that
// knows something was being created, and the reason is usually the name itself.
error = e.message
busy = false
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create in ${baseName(directory)}") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Directory", modifier = Modifier.weight(1f))
Switch(
checked = isDirectory,
onCheckedChange = { isDirectory = it },
enabled = !busy,
)
}
Text(
"A name that is already taken is refused rather than replaced.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
TextButton(onClick = { create() }, enabled = !busy && name.isNotBlank()) {
Text(if (busy) "Creating..." else "Create")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } },
)
}
/**
* Directories first, then by name ignoring case, and stably.
*
* Sorted here rather than by the machine: presentation order is a display decision, and `find`
* answers in whatever order the directory happens to be stored in. Dotfiles are not hidden -- in a
* repository they are half of what matters.
*/
internal fun sortForDisplay(entries: List<DirEntry>): List<DirEntry> =
entries.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
/**
* The directory above [path], or null at the root.
*
* A string operation on a path the *machine* resolved, which is what makes it safe: every listing
* answers with its own `pwd -P`, so there is never a `..` or a symlink left in here to reason
* about, and this app never has to resolve one.
*/
internal fun parentOf(path: String): String? {
val trimmed = path.trimEnd('/')
if (trimmed.isEmpty()) return null
val cut = trimmed.lastIndexOf('/')
return when {
cut < 0 -> null
cut == 0 -> "/"
else -> trimmed.substring(0, cut)
}
}
/** What a path names: its last segment, with `/` naming itself. */
internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/')
return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/')
}
internal fun join(directory: String, name: String): String =
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"
@@ -1,204 +0,0 @@
package com.example.aiapp
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.view.FrameMetrics
import android.view.Window
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
/**
* How long each frame took, and which phase of it, taken from the platform rather than from a frame
* counter of our own.
*
* The point of splitting it up is that "the scroll is laggy" has two completely different causes
* and one appearance. If the layout-and-measure and draw figures are small and the total is large,
* the time is going into rasterising and compositing, and no amount of doing less work per row will
* move it. If they are large, the work per row is the problem and it is ours to fix.
*
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
* broken into the parts the UI thread is responsible for and the parts after it.
*
* One of these for the app, like [DebugStats], because the two are read as one report and
* [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session
* and the counters were not, so a report copied after visiting two sessions divided every session's
* work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window.
*/
object FrameStats {
private val total = ArrayList<Long>()
private val waited = ArrayList<Long>()
private val input = ArrayList<Long>()
private val animation = ArrayList<Long>()
private val layout = ArrayList<Long>()
private val draw = ArrayList<Long>()
private val sync = ArrayList<Long>()
private val issue = ArrayList<Long>()
private val swap = ArrayList<Long>()
private val gpu = ArrayList<Long>()
private var since = System.currentTimeMillis()
/**
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
*/
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
private val phaseMarks = ArrayList<PhaseMark>()
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
@Synchronized
fun markPhase(name: String) {
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
}
@Synchronized
fun add(metrics: FrameMetrics) {
// The first frame after a window opens includes inflating it and is nobody's scroll.
if (metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 1L) return
if (total.size >= CAP) return
total += metrics.getMetric(FrameMetrics.TOTAL_DURATION)
// How long the frame waited for the UI thread to be free before it could start. Reported
// because the phases otherwise do not add up to the total, and the gap is the interesting
// part: the frame being held up by work that is not the frame's.
waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)
animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION)
layout += metrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION)
draw += metrics.getMetric(FrameMetrics.DRAW_DURATION)
sync += metrics.getMetric(FrameMetrics.SYNC_DURATION)
issue += metrics.getMetric(FrameMetrics.COMMAND_ISSUE_DURATION)
swap += metrics.getMetric(FrameMetrics.SWAP_BUFFERS_DURATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
gpu += metrics.getMetric(FrameMetrics.GPU_DURATION)
}
}
@Synchronized
fun reset() {
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
it.clear()
}
phaseMarks.clear()
since = System.currentTimeMillis()
}
@Synchronized
fun lines(refreshHz: Float): List<String> {
if (total.isEmpty()) return listOf(" no frames recorded -- scroll first, then press this")
val seconds = (System.currentTimeMillis() - since) / 1000.0
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
val late = total.count { it / 1_000_000.0 > budget }
return listOf(
" ${total.size} frames over ${"%.1f".format(seconds)}s" +
" at ${"%.0f".format(refreshHz)}Hz (${"%.1f".format(budget)}ms budget)",
" late: $late (${percent(late, total.size)})" +
if (total.size >= CAP) " [capped]" else "",
phase("total ", total),
phase("waited", waited),
phase("input ", input),
phase("anim ", animation),
phase("layout", layout),
phase("draw ", draw),
phase("sync ", sync),
phase("issue ", issue),
phase("swap ", swap),
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
}
/**
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
* that finished before a frame was drawn) still get a line rather than being silently dropped
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
*/
@Synchronized
fun phaseLines(refreshHz: Float): List<String> {
if (phaseMarks.isEmpty()) return emptyList()
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
val lines = ArrayList<String>()
phaseMarks.forEachIndexed { i, mark ->
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
val endMs =
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
else System.currentTimeMillis()
val samples = total.subList(mark.startIndex, endIndex)
val seconds = (endMs - mark.startMs) / 1000.0
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
if (samples.isEmpty()) {
lines += " no frames recorded in this phase"
} else {
val late = samples.count { it / 1_000_000.0 > budget }
lines += " late: $late (${percent(late, samples.size)})"
lines += " " + phase("total ", samples)
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
}
}
return lines
}
/** How long the frames recorded here spent in their draw phase, and how many there were. */
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
private fun phase(name: String, samples: List<Long>): String {
val sorted = samples.sorted()
return " $name p50 ${at(sorted, 50)} p90 ${at(sorted, 90)} p99 ${at(sorted, 99)}"
}
private fun at(sorted: List<Long>, percentile: Int): String {
if (sorted.isEmpty()) return "-"
val index = (sorted.size - 1) * percentile / 100
return "%.1fms".format(sorted[index] / 1_000_000.0)
}
private fun percent(part: Int, whole: Int) = "%.1f%%".format(100.0 * part / whole)
}
/** Enough for a couple of minutes of scrolling; this is a diagnostic, not a log. */
private const val CAP = 20_000
/**
* Records into [FrameStats] for as long as this screen is on it.
*
* The listener is what comes and goes; what it writes into does not, so a report covers the same
* stretch of time as the counters beside it.
*
* The listener is handed its own thread because the platform calls it for every frame and the
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
*/
@Composable
fun RecordFrames() {
val window = LocalContext.current.activity()?.window
DisposableEffect(window) {
if (window == null) return@DisposableEffect onDispose {}
val thread = HandlerThread("frame-stats").apply { start() }
val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ ->
FrameStats.add(metrics)
}
window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper))
onDispose {
window.removeOnFrameMetricsAvailableListener(listener)
thread.quitSafely()
}
}
}
/** The activity behind a composable's context, which is what owns the window. */
fun Context.activity(): Activity? {
var context: Context? = this
while (context is ContextWrapper) {
if (context is Activity) return context
context = context.baseContext
}
return null
}
/** What the display is actually refreshing at, so "late" is measured against the real budget. */
fun Context.refreshHz(): Float =
@Suppress("DEPRECATION") (activity()?.windowManager?.defaultDisplay?.refreshRate ?: 60f)
@@ -1,316 +0,0 @@
package com.example.aiapp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
/** What a span of code is, in the terms the palette has a colour for. */
enum class Kind {
KEYWORD,
STRING,
LITERAL,
COMMENT,
METADATA,
PUNCTUATION,
MARK,
}
/** A run of [Kind] in the code, as a half-open range. */
data class Span(val start: Int, val end: Int, val kind: Kind)
/**
* The colours the highlighter draws with, ours rather than a library's; [catppuccinSyntax] is the
* one instance and lives with the rest of the palette.
*/
data class SyntaxPalette(
val keyword: Color,
val string: Color,
val literal: Color,
val comment: Color,
val metadata: Color,
val punctuation: Color,
val mark: Color,
) {
fun of(kind: Kind): Color =
when (kind) {
Kind.KEYWORD -> keyword
Kind.STRING -> string
Kind.LITERAL -> literal
Kind.COMMENT -> comment
Kind.METADATA -> metadata
Kind.PUNCTUATION -> punctuation
Kind.MARK -> mark
}
}
/**
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
*
* Shared by a tool call's input and a reply's fences, so the same code is the same colours wherever
* it appears.
*
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
* off the drawing thread.
*
* The timing is the number the highlighter is judged by: the library this replaced took **174ms**
* on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted]
* caches the answer rather than a `remember` inside the fence recomputing it on every scroll back.
*/
fun highlight(code: String, language: Language?): AnnotatedString {
if (language == null) return AnnotatedString(code)
val spans = DebugStats.timed("code highlighted") { spansOf(code, language) }
val palette = catppuccinSyntax()
return buildAnnotatedString {
append(code)
spans.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
}
}
/**
* [code] read once, left to right, into the spans that carry a colour.
*
* One pass with a small state -- in a comment, in a string, or in ordinary code -- rather than a
* locator per token kind over the whole text, which is what the library did and is why it found
* comments before it knew the language: a `#` inside a shell string, a `//` inside a URL and a
* block-comment opener inside a shell glob each commented out the rest of a line that was nothing
* of the sort.
*
* Every span is produced by advancing an index forward, so the result is ordered, non-overlapping
* and inside the code by construction. Nothing here throws: an unterminated string or comment runs
* to the end of the code, which is also what it looks like while a fence is still being written.
*
* In ordinary code the order of recognition is comment, string, attribute, number, word, and
* finally a single punctuation or mark character, which are coloured only in ordinary code.
*/
fun scan(code: String, rules: Rules): List<Span> = Scanner(code, rules).run()
/** Characters coloured as punctuation, and as marks. Both sets are the ones the library used. */
private const val PUNCTUATION = ",.:;"
private const val MARKS = "()={}<>-+[]|&"
private class Scanner(private val code: String, private val rules: Rules) {
private val spans = ArrayList<Span>()
private var at = 0
fun run(): List<Span> {
while (at < code.length) {
// Every branch that answers true has advanced `at`, so this terminates.
val consumed =
blockComment() ||
lineComment() ||
rawString() ||
characterOrLifetime() ||
string() ||
attribute() ||
number() ||
word() ||
singleCharacter()
if (!consumed) at++
}
return spans
}
private fun emit(start: Int, kind: Kind) {
if (at > start) spans.add(Span(start, at, kind))
}
private fun starts(token: String) = code.startsWith(token, at)
/** Whether a line comment token here opens one; see [Rules.lineCommentsAtWordStart]. */
private fun atWordStart() = at == 0 || code[at - 1].isWhitespace() || code[at - 1] in ";|&("
/** Whether only whitespace stands between the start of this line and here. */
private fun atLineStart(): Boolean {
var back = at - 1
while (back >= 0 && code[back] != '\n') {
if (!code[back].isWhitespace()) return false
back--
}
return true
}
private fun toEndOfLine() {
while (at < code.length && code[at] != '\n') at++
}
/** From an open bracket through the one that matches it, or to the end if none does. */
private fun toMatchingBracket() {
var depth = 0
while (at < code.length) {
when (code[at]) {
'[' -> depth++
']' -> depth--
}
at++
if (depth == 0) return
}
}
private fun blockComment(): Boolean {
val comment = rules.blockComment ?: return false
if (!starts(comment.open)) return false
val start = at
at += comment.open.length
var depth = 1
while (at < code.length && depth > 0) {
// The closer is tried first so that a language whose two delimiters are the same string
// -- CoffeeScript's `###` -- closes rather than nesting forever.
if (starts(comment.close)) {
depth--
at += comment.close.length
} else if (comment.nests && starts(comment.open)) {
depth++
at += comment.open.length
} else {
at++
}
}
emit(start, Kind.COMMENT)
return true
}
private fun lineComment(): Boolean {
if (rules.lineComments.none { starts(it) }) return false
if (rules.lineCommentsAtWordStart && !atWordStart()) return false
val start = at
toEndOfLine()
emit(start, Kind.COMMENT)
return true
}
/** Rust and RON: `b`? `r` `#`* `"` … `"` `#`*, with no escapes inside. */
private fun rawString(): Boolean {
if (!rules.rawStrings) return false
var ahead = at
if (code.getOrNull(ahead) == 'b') ahead++
if (code.getOrNull(ahead) != 'r') return false
ahead++
var hashes = 0
while (code.getOrNull(ahead) == '#') {
ahead++
hashes++
}
if (code.getOrNull(ahead) != '"') return false
val start = at
val closer = "\"" + "#".repeat(hashes)
val closed = code.indexOf(closer, ahead + 1)
at = if (closed < 0) code.length else closed + closer.length
emit(start, Kind.STRING)
return true
}
/** See [Rules.lifetimes]: an apostrophe that is not a character literal opens nothing. */
private fun characterOrLifetime(): Boolean {
if (!rules.lifetimes || code[at] != '\'') return false
val next = code.getOrNull(at + 1) ?: return false
if (next == '\\' || code.getOrNull(at + 2) == '\'') {
quoted(Quote("'", "'", escapes = true))
} else {
at++
}
return true
}
private fun string(): Boolean {
// Longest opener wins, so Kotlin's `"""` is one delimiter rather than an empty string
// followed by a quote. A loop rather than filter/maxBy: this runs at every character of
// ordinary code, and the pair of lists that would allocate is the whole cost of the scan.
var quote: Quote? = null
for (candidate in rules.quotes) {
if (starts(candidate.open) && candidate.open.length > (quote?.open?.length ?: 0)) {
quote = candidate
}
}
quoted(quote ?: return false)
return true
}
private fun quoted(quote: Quote) {
val start = at
at += quote.open.length
while (at < code.length) {
if (quote.escapes && code[at] == '\\' && at + 1 < code.length) {
at += 2
continue
}
if (starts(quote.close)) {
at += quote.close.length
break
}
at++
}
at = at.coerceAtMost(code.length)
emit(start, Kind.STRING)
}
private fun attribute(): Boolean {
val start = at
when (rules.attributes) {
Attributes.NONE -> return false
Attributes.AT_WORD -> {
if (code[at] != '@' || !isWordStart(code.getOrNull(at + 1))) return false
at++
while (at < code.length && isWordPart(code[at])) at++
}
Attributes.HASH_BRACKET -> {
if (code[at] != '#') return false
var ahead = at + 1
if (code.getOrNull(ahead) == '!') ahead++
if (code.getOrNull(ahead) != '[') return false
at = ahead
toMatchingBracket()
}
Attributes.HASH_LINE -> {
if (code[at] != '#' || !atLineStart()) return false
toEndOfLine()
}
Attributes.LINE_BRACKET -> {
if (code[at] != '[' || !atLineStart()) return false
toMatchingBracket()
}
}
emit(start, Kind.METADATA)
return true
}
/**
* A number is a run starting with a digit and carrying on through letters, digits, `_` and `.`
* -- which covers `0xFF`, `1_000`, `1u32` and `3.14` without a grammar for any of them.
*/
private fun number(): Boolean {
if (!code[at].isDigit()) return false
val start = at
while (
at < code.length && (code[at].isLetterOrDigit() || code[at] == '_' || code[at] == '.')
) {
at++
}
emit(start, Kind.LITERAL)
return true
}
private fun word(): Boolean {
if (!isWordStart(code[at])) return false
val start = at
while (at < code.length && isWordPart(code[at])) at++
if (code.substring(start, at) in rules.keywords) emit(start, Kind.KEYWORD)
return true
}
private fun singleCharacter(): Boolean {
val kind =
when (code[at]) {
in PUNCTUATION -> Kind.PUNCTUATION
in MARKS -> Kind.MARK
else -> return false
}
at++
emit(at - 1, kind)
return true
}
}
private fun isWordStart(c: Char?) = c != null && (c.isLetter() || c == '_')
private fun isWordPart(c: Char) = c.isLetterOrDigit() || c == '_'
@@ -1,667 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** What a row says about itself while an operation is running on it. See [BusyItem]. */
private const val IMPORTING = "importing"
private const val DELETING = "deleting"
/**
* What the rows further down a batch say while they wait their turn.
*
* Its own word rather than the operation's, because nothing has been done to this session yet, so a
* batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over
* all the same -- a queued row that still looked ordinary was still tappable.
*/
private const val WAITING = "waiting"
/**
* How long a row that has just moved ignores being touched.
*
* A batch takes rows out of the list as each one lands, so everything below the one that went
* slides up -- and a tap already on its way then arrives at whichever row moved into that place. On
* this screen that means importing a session nobody chose.
*
* Swallowed silently rather than shown, because anything drawn on every row a batch passes would be
* a flicker running down the list.
*/
private const val SETTLE_MS = 500L
/**
* Continuing a Claude Code session the machine already has.
*
* The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so
* an enrolled phone cannot turn this screen into a file reader.
*
* Holding a row selects it and puts the screen in selection mode, where the options that act on a
* selection appear along the bottom. That exists because these arrive in bulk -- a machine
* accumulates dozens of abandoned sessions -- and one confirmation dialog per row is the reason
* clearing them out was not worth doing.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
val scope = rememberCoroutineScope()
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Setup?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
// than a flag per row, because the rows are rebuilt from whatever the server last said and this
// belongs to the request rather than to the session.
var running by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which rows the reader has picked out. Empty means selection mode is off: a selection mode
// with nothing selected is a state with no controls in it and no way to leave except Back.
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
// Failures that belong to one row rather than to the screen, shown on that row. A batch is
// exactly where a single banner fails: nine deletes succeeded and one did not, and the banner
// cannot say which.
var rowErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
// themselves, not a flag, so the dialog can say what it is about.
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
// Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty
// times.
var permissionMode by remember { mutableStateOf("auto") }
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
// so a tap reading it needs no recomposition.
val movedAt = remember { mutableMapOf<String, Long>() }
fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS
/**
* Fetches the list and takes the row states from it.
*
* Taken from the answer rather than kept across the load: the server is what knows what is
* running, and this screen may be opening on work another phone started.
*/
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
try {
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
LoadState.Loaded(rows)
} catch (err: Exception) {
LoadState.Error(err.message ?: "Couldn't list sessions")
}
fun loadSessions(setup: Setup) {
sessions = LoadState.Loading
selected = emptySet()
scope.launch { sessions = fetchInto(setup) }
}
/** Takes a row out of the list, once the machine no longer has it to offer. */
fun forget(id: String) {
val loaded = sessions
if (loaded is LoadState.Loaded) {
// Only this row, and only what changed -- refetching instead put every other row back
// through a loading spinner to report a change that was never in doubt.
sessions = LoadState.Loaded(loaded.value.filterNot { it.id == id })
}
}
LaunchedEffect(reloadToken) {
setups =
try {
val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
found.firstOrNull()?.let {
chosen = it
loadSessions(it)
}
LoadState.Loaded(found)
} catch (err: Exception) {
LoadState.Error(err.message ?: "Couldn't list machines")
}
}
/**
* Hands [targets] to the server in one request, marking every row it covers.
*
* The request only *starts* the work -- the server runs it and says how each row went on the
* change stream, which is what lets this screen be left while a batch is still going.
*
* Marked [WAITING] rather than with the operation's own word until the server confirms. Between
* the request leaving and the `started` event coming back, "we have asked" is the truth and "it
* is importing" is a guess.
*
* The selection is dropped as the work is handed over, not when it finishes: the screen goes
* back to how it started, and what says the work is happening is the rows it is happening to.
*/
fun handOver(targets: List<Importable>, send: suspend (List<String>) -> Unit) {
selected = emptySet()
running = running + targets.associate { it.id to WAITING }
rowErrors = rowErrors - targets.map { it.id }.toSet()
val setup = chosen
val ids = targets.map { it.id }
scope.launch {
// One request for the whole batch, not one per row. Sent row by row, a handover was
// only as atomic as the network, and what came back was some rows running and some
// untouched -- indistinguishable, on the list, from rows nobody had picked.
try {
withContext(Dispatchers.IO) { send(ids) }
} catch (err: Exception) {
// The server never took it, so nothing is running and no event will arrive to say
// so. This is the one failure the screen must report itself -- and it is the whole
// batch's failure, which is the point: no row was singled out.
running = running - ids.toSet()
rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" }
return@launch
}
// Then ask what actually happened, if anything still looks outstanding.
//
// The change stream is a broadcast with no memory, so an operation that started and
// finished while it was still connecting is one nothing will ever be said about -- and
// the row sits marked for ever. That is not hypothetical: with responses held back far
// enough, one row of a pair of deletes cleared and the other stayed on "waiting".
//
// The listing is the repair, because it carries the same state the events do. Only when
// something still looks outstanding, so the ordinary case does not pay for a second
// listing, which is the most expensive call this screen makes.
if (setup != null && targets.any { running.containsKey(it.id) }) {
// Quietly: no Loading, because blanking the list to report on rows that are already
// saying what is happening to them is the flicker this screen avoids everywhere
// else.
sessions = fetchInto(setup)
}
}
}
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
/** Continues [targets] in the background, leaving the screen where it is. */
fun importAll(targets: List<Importable>) {
val setup = chosen ?: return
val useProvider = provider ?: return
handOver(targets) { ids ->
startImport(
settings,
setup = setup.id,
sessionIds = ids,
provider = useProvider.name,
permissionMode = permissionMode,
)
}
}
/**
* Continues one session and goes to it.
*
* The tap keeps waiting, because "take me there" needs the session it made and the server's
* accepted-and-running answer does not carry one. It is one session and somebody is watching
* it, which is the case where waiting is the right thing anyway.
*/
fun importAndOpen(target: Importable) {
val setup = chosen ?: return
val useProvider = provider ?: return
running = running + (target.id to IMPORTING)
rowErrors = rowErrors - target.id
scope.launch {
try {
val spawned =
withContext(Dispatchers.IO) {
spawnSession(
settings,
setup = setup.id,
provider = useProvider.name,
// Nothing to say: the server titles it from the session it continues.
title = "",
permissionMode = permissionMode,
import = target.id,
)
}
forget(target.id)
onImported(spawned)
} catch (err: Exception) {
rowErrors = rowErrors + (target.id to (err.message ?: "Couldn't import that one"))
} finally {
running = running - target.id
}
}
}
// Live changes to what the server is doing to these sessions, for as long as this screen is up.
// The listing already carried the same state when the screen opened -- this is what keeps it
// current afterwards, including for work another phone started.
//
// Failures here are deliberately quiet. There is nothing for a reader to do about a dropped
// event stream, and every state it would have carried is in the next listing.
val liveChanges = remember {
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
}
LaunchedEffect(chosen?.id) {
val setup = chosen?.id ?: return@LaunchedEffect
try {
while (true) {
val stream = ImportableStream(settings, setup)
liveChanges.set(stream)
try {
withContext(Dispatchers.IO) {
stream.run(onOpen = {}) { change ->
when (change.state) {
"started" ->
running =
running + (change.session to (change.operation ?: WAITING))
// Gone from the machine either way: a delete removed the
// transcript, an import made it a session.
"finished" -> {
running = running - change.session
forget(change.session)
}
"failed" -> {
running = running - change.session
rowErrors =
rowErrors +
(change.session to (change.message ?: "Didn't work"))
}
}
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
// The screen leaving, not a failure -- and swallowing it would leave this loop
// reconnecting to a stream nobody is watching.
throw e
} catch (_: Exception) {
// Retried below; the listing is the truth in the meantime. Any failure, not
// only an [ApiException]: a stream is an optimisation over the listing here,
// and catching only the expected failure means an unexpected one closes the app
// from a screen that is merely loading a list.
} finally {
stream.close()
}
delay(RECONNECT_DELAY_MS)
}
} finally {
// Cancellation cannot interrupt a blocking socket read; closing is what unblocks it.
liveChanges.getAndSet(null)?.close()
}
}
// The screen leaving the composition entirely, which the effect above does not cover.
DisposableEffect(chosen?.id) { onDispose { liveChanges.get()?.close() } }
// Back leaves selection mode rather than the tab, which is the level it is one step above.
// Nested inside MainScreen's own handler, so it wins while there is a selection.
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
// can still be scrolled to while it is up.
var barHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
// No heading: the tab that selected this one already says "Import". The sentence below
// stays, because it says what importing *does*, which the tab label cannot.
Text(
"Sessions Claude Code already has on the machine. Importing continues one where " +
"it left off; the transcript here shows its recent history. Hold one to " +
"select it, and several at a time.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
when (val loaded = setups) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> {
// Only worth choosing when there is a choice.
if (loaded.value.size > 1) {
Row(Modifier.fillMaxWidth()) {
loaded.value.forEach { setup ->
TextButton(
onClick = {
chosen = setup
loadSessions(setup)
}
) {
Text(
setup.name,
color =
if (setup.id == chosen?.id)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (chosen != null && provider == null) {
Text(
"${chosen?.name} has no Claude CLI, so there is nothing here to " +
"continue.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Permissions",
options = PERMISSION_MODES,
selected = permissionMode,
onSelect = { permissionMode = it },
)
Spacer(Modifier.height(8.dp))
ImportableList(
state = sessions,
running = running,
settling = ::settling,
selected = selected,
errors = rowErrors,
bottomInset = barHeight,
onToggle = { session ->
selected =
if (session.id in selected) selected - session.id
else selected + session.id
},
onOpen = { session -> importAndOpen(session) },
)
}
}
}
}
// Beside nothing in particular, because a selection is not one row: the options that act on
// it belong to the screen, and the bottom is where a thumb already is.
if (selected.isNotEmpty()) {
val picked =
(sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
SelectionBar(
count = picked.size,
modifier =
Modifier.align(Alignment.BottomCenter).onSizeChanged {
barHeight = with(density) { it.height.toDp() }
},
onDelete = { confirming = picked },
onImport = { importAll(picked) },
)
}
}
confirming?.let { targets ->
AlertDialog(
onDismissRequest = { confirming = null },
title = {
Text(
if (targets.size == 1) "Delete this session?"
else "Delete ${targets.size} sessions?"
)
},
text = {
Text(
// One name is worth showing and twelve are not, so the count stands in for
// them. The sentence after it is the same either way, because what deleting
// costs does not change with how many.
(if (targets.size == 1) "\"${targets.first().title}\"\n\n" else "") +
"Claude Code keeps no copy: its transcript is the session, so this ends " +
"any chance of resuming that conversation. Sessions already imported " +
"here keep the history they replayed, but cannot be continued."
)
},
confirmButton = {
TextButton(
onClick = {
val setup = chosen ?: return@TextButton
confirming = null
handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) }
}
) {
// Coloured by consequence: this takes something away, wherever it appears.
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
)
}
}
/**
* What can be done to the rows that are selected.
*
* Delete and Import only, for now: they are the two things this screen has ever done to a session,
* and an option that appears here has to work on every row in a selection.
*/
@Composable
private fun SelectionBar(
count: Int,
modifier: Modifier = Modifier,
onDelete: () -> Unit,
onImport: () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 3.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
"$count selected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDelete) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.width(4.dp))
TextButton(onClick = onImport) { Text("Import") }
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ImportableList(
state: LoadState<List<Importable>>,
/** Rows an operation is running on, as the word each one shows. */
running: Map<String, String>,
/** Whether this row has just moved and should ignore being touched -- see [SETTLE_MS]. */
settling: (String) -> Boolean,
selected: Set<String>,
errors: Map<String, String>,
/** What the selection bar covers, so the last row can still be reached under it. */
bottomInset: Dp,
onToggle: (Importable) -> Unit,
onOpen: (Importable) -> Unit,
) {
when (state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(state.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
if (state.value.isEmpty()) {
Text(
"No Claude Code sessions on that machine.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
val selecting = selected.isNotEmpty()
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
uniqueItems(state.value, key = { it.id }) { session ->
val picked = session.id in selected
BusyItem(label = running[session.id]) {
Card(
colors =
if (picked)
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.secondaryContainer,
contentColor =
MaterialTheme.colorScheme.onSecondaryContainer,
)
else CardDefaults.cardColors(),
modifier =
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.combinedClickable(
// Off while something is happening to this row -- see
// [BusyItem], which draws that but leaves the gestures
// alone so the list still scrolls.
enabled = running[session.id] == null,
onClick = {
if (settling(session.id)) return@combinedClickable
// In selection mode a tap is a selection, so the
// reader is never one mis-tap away from starting a
// CLI they were only picking rows for.
//
// Outside it, a tap continues the session -- except
// on a row that cannot be continued, where it
// selects instead. That row's only remaining action
// is Delete, and a tap that did nothing at all
// would be a worse answer. Two `--resume` processes
// on one transcript each replay the other's writes,
// which is why this must not simply try.
if (selecting || session.inUse == "yes")
onToggle(session)
else onOpen(session)
},
onLongClick = {
if (!settling(session.id)) onToggle(session)
},
),
) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.Top) {
Text(
session.title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
// Beside the title, because "which one was I just in" is
// the question this list answers and the order already
// reflects it.
Text(
relativeTime(session.modified),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(4.dp))
// The path first, and the only thing here that is cut: it is
// one long value with no natural break. Cut at the head,
// because a path is identified by its tail and these all share
// a long prefix. By the row's real width rather than a
// character count, which was one guess for every font size and
// screen.
session.cwd
.takeIf { it.isNotEmpty() }
?.let { cwd ->
Text(
cwd,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
statsOf(session),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Its own line and its own colour, because it differs in kind
// from the stats above rather than in degree: those describe
// the session, this says whether taking it is safe at all.
warningOf(session)?.let { warning ->
Text(
warning,
style = MaterialTheme.typography.bodySmall,
color = warningColor,
)
}
// Reported where it happened, in the server's own words.
errors[session.id]?.let { message ->
Spacer(Modifier.height(4.dp))
Text(
message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
}
}
}
/** What this session is: the measurements, in the order they are worth knowing. */
private fun statsOf(session: Importable): String =
listOfNotNull(
// Said, because a name and a last message are different claims: one describes the
// session, the other is only what happened last in it.
if (session.named) "named" else null,
// What continuing it costs, which is the question this list is really asked. Absent
// rather than zero when nothing has been measured -- a session with no turns yet has no
// figure, not a figure of none.
session.contextTokens?.let { "${it / 1000}k context" },
"${session.lines} lines",
// Kept beside the context figure because the two disagree usefully: most of a large
// transcript is history from before a compaction, so a big file can be cheap to
// continue.
humanSize(session.bytes),
)
.joinToString(" · ")
/**
* Why this session might not be safe to take, if it isn't.
*
* Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind,
* and no shade distinguishes them.
*/
private fun warningOf(session: Importable): String? =
when (session.inUse) {
// What was measured is that a live process on that machine holds this session open. Which
// process is not measured, so it isn't claimed: "a terminal -- close it there first" sent
// people looking for a window that need not exist. Naming a place the reader then can't
// find turns a correct refusal into a wrong instruction.
"yes" -> "something on that machine is running it"
"unknown" -> "can't tell if it's open"
else -> null
}
@@ -1,25 +0,0 @@
package com.example.aiapp
/**
* What a machine's Claude Code sessions are having done to them, live.
*
* The import screen starts and then leaves work behind: the server runs it, so the phone that asked
* is free to go elsewhere and the answer arrives here rather than as a reply. What the screen shows
* on arrival comes from the listing, which carries the same state for whoever was not connected
* when it changed; this is only what keeps a screen somebody is watching current.
*
* The connection and its framing belong to [Sse]. Closing is the caller's cancellation path, and
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
* the next listing.
*/
class ImportableStream(settings: ServerSettings, private val setup: String) {
private val stream = Sse(settings)
fun close() = stream.close()
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
stream.run("/setups/$setup/importable/events", onOpen) { _, data ->
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
}
}
}
@@ -1,453 +0,0 @@
package com.example.aiapp
/**
* A language the highlighter can colour.
*
* The names the reader writes after the backticks are aliases onto these; [fenceLanguage] holds
* that table. A word with no entry there is null, and null is drawn plain, because a fence coloured
* by another language's rules looks highlighted and is wrong in a way the reader cannot see.
*
* Nearly all of them are a row of [RULES], read by one shared scanner. [MARKDOWN] is the one that
* is not; see [spansOf].
*/
enum class Language {
C,
COFFEESCRIPT,
CPP,
CSHARP,
DART,
FISH,
GO,
JAVA,
JAVASCRIPT,
JSON,
KOTLIN,
MARKDOWN,
PERL,
PHP,
PYTHON,
RON,
RUBY,
RUST,
SHELL,
SWIFT,
TOML,
TYPESCRIPT,
}
/**
* What [scan] needs to know about one language -- data, not code, so that adding a language is a
* row in [RULES] rather than a branch anywhere.
*
* The two forms that could not be expressed as data are flags here and a few lines in the scanner:
* [rawStrings], because the closing delimiter depends on how many hashes the opener had, and
* [lifetimes], because whether `'` opens anything at all depends on what follows it.
*/
data class Rules(
/** Words drawn as keywords. Only plain words; the scanner cannot reach anything else. */
val keywords: Set<String>,
/** Tokens that open a comment running to the end of the line. */
val lineComments: List<String> = emptyList(),
/**
* Whether [lineComments] count only at the start of a word. The shells need it: `$#`, `${#x}`
* and `a#b` are not comments, and greying the rest of those lines is one of the mistakes this
* scanner exists to stop.
*/
val lineCommentsAtWordStart: Boolean = false,
val blockComment: BlockComment? = null,
/** The string forms. The longest opener that matches wins, so `"""` is tried before `"`. */
val quotes: List<Quote> = emptyList(),
val attributes: Attributes = Attributes.NONE,
/** Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes. */
val rawStrings: Boolean = false,
/**
* Rust: `'` opens a character literal only when a backslash or one character and a `'` follow.
* Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the
* next apostrophe in the block.
*/
val lifetimes: Boolean = false,
)
data class BlockComment(val open: String, val close: String, val nests: Boolean)
/** One string form. [escapes] is whether a backslash escapes the closer (and itself). */
data class Quote(val open: String, val close: String, val escapes: Boolean)
/** What opens a metadata span, of the shapes that exist across these languages. */
enum class Attributes {
NONE,
/** `@` and a word: Kotlin and Java annotations, Python decorators. */
AT_WORD,
/** `#[` or `#![` through the matching `]`: Rust and RON attributes. */
HASH_BRACKET,
/** `#` at the start of a line, to the end of it: the C preprocessor. */
HASH_LINE,
/** `[` at the start of a line through the matching `]`: a TOML table header. */
LINE_BRACKET,
}
/**
* The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to
* be made of.
*
* Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner.
* Markdown has none of those, and what a character means there depends on where on the line it
* sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a
* row of rules or an entry in [SCANNERS], and no caller learns which one it got.
*/
fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(language)(code)
// Lazy for the same reason [RULES] is, since it reads it.
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
mapOf(Language.MARKDOWN to ::scanMarkdown)
}
private val C_STYLE = BlockComment("/*", "*/", nests = false)
private val NESTING = BlockComment("/*", "*/", nests = true)
private val DOUBLE = Quote("\"", "\"", escapes = true)
private val SINGLE = Quote("'", "'", escapes = true)
private val TRIPLE_DOUBLE = Quote("\"\"\"", "\"\"\"", escapes = true)
private val TRIPLE_SINGLE = Quote("'''", "'''", escapes = true)
// Lazy because the keyword sets below are top-level properties too, and a file's properties
// initialize in the order they are written: read eagerly here, every set would be null.
private val RULES: Map<Language, Rules> by lazy {
mapOf(
Language.C to
Rules(
keywords = KEYWORDS_C,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
attributes = Attributes.HASH_LINE,
),
Language.CPP to
Rules(
keywords = KEYWORDS_CPP,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
attributes = Attributes.HASH_LINE,
),
Language.CSHARP to
Rules(
keywords = KEYWORDS_CSHARP,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
),
// `###` opens and closes a block comment and `#` opens a line one, which is why the scanner
// tries the block opener first.
Language.COFFEESCRIPT to
Rules(
keywords = KEYWORDS_COFFEESCRIPT,
lineComments = listOf("#"),
blockComment = BlockComment("###", "###", nests = false),
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
),
Language.DART to
Rules(
keywords = KEYWORDS_DART,
lineComments = listOf("//"),
blockComment = NESTING,
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
attributes = Attributes.AT_WORD,
),
Language.FISH to
Rules(
keywords = KEYWORDS_FISH,
lineComments = listOf("#"),
lineCommentsAtWordStart = true,
// fish's single quotes escape only `\'` and `\\`, which is what "skip the character
// after a backslash" already does.
quotes = listOf(DOUBLE, SINGLE),
),
Language.GO to
Rules(
keywords = KEYWORDS_GO,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = false)),
),
Language.JAVA to
Rules(
keywords = KEYWORDS_JAVA,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
attributes = Attributes.AT_WORD,
),
Language.JAVASCRIPT to
Rules(
keywords = KEYWORDS_JAVASCRIPT,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)),
),
Language.JSON to Rules(keywords = KEYWORDS_JSON, quotes = listOf(DOUBLE)),
Language.KOTLIN to
Rules(
keywords = KEYWORDS_KOTLIN,
lineComments = listOf("//"),
blockComment = NESTING,
quotes = listOf(Quote("\"\"\"", "\"\"\"", escapes = false), DOUBLE, SINGLE),
attributes = Attributes.AT_WORD,
),
Language.PERL to
Rules(
keywords = KEYWORDS_PERL,
lineComments = listOf("#"),
quotes = listOf(DOUBLE, SINGLE),
),
Language.PHP to
Rules(
keywords = KEYWORDS_PHP,
lineComments = listOf("//", "#"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
attributes = Attributes.AT_WORD,
),
Language.PYTHON to
Rules(
keywords = KEYWORDS_PYTHON,
lineComments = listOf("#"),
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
attributes = Attributes.AT_WORD,
),
Language.RON to
Rules(
keywords = KEYWORDS_RON,
lineComments = listOf("//"),
blockComment = NESTING,
quotes = listOf(DOUBLE, SINGLE),
attributes = Attributes.HASH_BRACKET,
rawStrings = true,
),
Language.RUBY to
Rules(
keywords = KEYWORDS_RUBY,
lineComments = listOf("#"),
quotes = listOf(DOUBLE, SINGLE),
),
Language.RUST to
Rules(
keywords = KEYWORDS_RUST,
lineComments = listOf("//"),
blockComment = NESTING,
// No `'` here: [Rules.lifetimes] decides when one opens a character literal.
quotes = listOf(DOUBLE),
attributes = Attributes.HASH_BRACKET,
rawStrings = true,
lifetimes = true,
),
Language.SHELL to
Rules(
keywords = KEYWORDS_SHELL,
lineComments = listOf("#"),
lineCommentsAtWordStart = true,
// A shell's single quotes are literal: `'a\'` is not one string.
quotes = listOf(DOUBLE, Quote("'", "'", escapes = false)),
),
Language.SWIFT to
Rules(
keywords = KEYWORDS_SWIFT,
lineComments = listOf("//"),
blockComment = NESTING,
quotes = listOf(TRIPLE_DOUBLE, DOUBLE),
attributes = Attributes.AT_WORD,
),
Language.TOML to
Rules(
keywords = KEYWORDS_TOML,
lineComments = listOf("#"),
quotes =
listOf(
TRIPLE_DOUBLE,
Quote("'''", "'''", escapes = false),
DOUBLE,
Quote("'", "'", escapes = false),
),
attributes = Attributes.LINE_BRACKET,
),
Language.TYPESCRIPT to
Rules(
keywords = KEYWORDS_TYPESCRIPT,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)),
attributes = Attributes.AT_WORD,
),
)
}
/**
* The keyword sets.
*
* Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0
* (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns
* plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family,
* Ruby's `defined?` -- because the word scanner cannot reach them.
*/
private fun words(list: String): Set<String> =
list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet()
private val KEYWORDS_C =
words(
"""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"""
)
private val KEYWORDS_CPP =
words(
"""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"""
)
private val KEYWORDS_CSHARP =
words(
"""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"""
)
private val KEYWORDS_COFFEESCRIPT =
words(
"""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"""
)
private val KEYWORDS_DART =
words(
"""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"""
)
/**
* fish is not in the library at all, so its fences are drawn plain today. The list is the shell's
* own words, which is what a fish fence is mostly made of.
*/
private val KEYWORDS_FISH =
words(
"""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"""
)
private val KEYWORDS_GO =
words(
"""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"""
)
private val KEYWORDS_JAVA =
words(
"""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"""
)
private val KEYWORDS_JAVASCRIPT =
words(
"""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"""
)
private val KEYWORDS_JSON = words("true false null")
private val KEYWORDS_KOTLIN =
words(
"""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"""
)
private val KEYWORDS_PERL =
words(
"""__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"""
)
private val KEYWORDS_PHP =
words(
"""__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"""
)
private val KEYWORDS_PYTHON =
words(
"""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"""
)
/** RON is not in the library either; these are the words a RON file can hold. */
private val KEYWORDS_RON = words("true false Some None inf NaN")
private val KEYWORDS_RUBY =
words(
"""__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"""
)
private val KEYWORDS_RUST =
words(
"""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"""
)
private val KEYWORDS_SHELL =
words(
"""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"""
)
private val KEYWORDS_SWIFT =
words(
"""_ 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"""
)
/** TOML is not in the library; `inf` and `nan` are values rather than names, like the booleans. */
private val KEYWORDS_TOML = words("true false inf nan")
private val KEYWORDS_TYPESCRIPT =
words(
"""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"""
)
@@ -1,28 +0,0 @@
package com.example.aiapp
/**
* What a screen knows about something it had to fetch: still finding out, got it, or couldn't.
*
* Three states rather than a value alongside a nullable error, because "we couldn't find out" must
* not share a representation with "there is nothing" -- a failed fetch would otherwise render as an
* empty list, which is the one wrong answer that looks like a right one.
*
* [Loading] and [Error] carry no payload, so they are `LoadState<Nothing>` and this is covariant in
* [T]: one `LoadState.Loading` serves every screen.
*/
sealed class LoadState<out T> {
data object Loading : LoadState<Nothing>()
data class Loaded<out T>(val value: T) : LoadState<T>()
data class Error(val message: String) : LoadState<Nothing>()
companion object {
/**
* The failure a fetch produces. Api.kt writes its messages to be read on this screen, so
* this passes one through rather than replacing it; the fallback covers only a throwable
* with no message at all, which [ApiException] never is.
*/
fun failed(e: ApiException): Error = Error(e.message ?: "Unknown error")
}
}
@@ -1,246 +0,0 @@
package com.example.aiapp
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.layout
import androidx.core.view.WindowCompat
class MainActivity : ComponentActivity() {
// Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the
// stored settings.
private var settingsVersion by mutableIntStateOf(0)
// The session a notification tap asked for, or null if nothing has. The serial is what makes a
// second tap on the same session's notification a second request: without it the two compare
// equal and the composition below has nothing to react to.
private var openRequest by mutableStateOf<SessionOpenRequest?>(null)
private var opens = 0
// What another app shared into this one, for the same reason and with the same serial.
private var shareRequest by mutableStateOf<ShareRequest?>(null)
private var shares = 0
// Registered up front since permission launchers must be registered before the activity reaches
// STARTED.
private val requestLocalNetworkPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
/**
* The service starts either way, and posts nothing if this is refused.
*
* Deliberately not gated on the answer: the permission can be granted later from Android's own
* settings, and a service that only ever started at the moment it was granted would stay down
* until the app was launched again.
*/
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Before anything else that could throw, so the first crash of a launch is caught too.
installCrashLog(this)
// Transparent status bar on every version; the Surface below paints through underneath it
// and content insets itself. Same reasoning as dev-updater's MainActivity.
enableEdgeToEdge()
// The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session
// screen against BenchFixture's in-process fake backend, with no enrollment, no network
// permission, and no notification prompt -- none of them mean anything with no server and
// no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built
// to talk to a real backend is made to talk to this instead. Still needs the same
// status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the
// platform's own default from Android 15 on this app's targetSdk, with or without the call
// above, so skipping the padding here put the header's own buttons under the status bar --
// there to look at, but not there for `ui-trace`'s tap-by-label to land on.
if (BuildConfig.FIXTURE_MODE) {
installFixtureNetworkOnce()
BenchFixture.ensureLoaded(this)
setContent {
MaterialTheme(colorScheme = AiAppColors) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
SessionScreen(
settings = BenchFixture.settings,
summary = benchSessionSummary(),
onBack = { finish() },
onFiles = {},
)
}
}
}
}
return
}
// Dark status-bar icons only over a light background, decided from the scheme rather than
// fixed. It was hardcoded to `true`, which was right against the default light surface and
// became unreadable the moment the app wore Catppuccin Mocha.
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
AiAppColors.background.luminance() > 0.5f
// Android 17+ silently drops local-network traffic without this; requested up front because
// a denial is invisible at the socket layer (it just times out).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
handleIntent(intent)
// After enrollment, so a first launch that arrives with a token starts the service with
// something to connect to rather than stopping it and waiting for the next launch.
NotificationService.sync(this)
setContent {
// Selection colours with the theme rather than at each place text is drawn: the
// transcript is one selection container, and a selection that ran from a reply into the
// code block under it would otherwise change colour halfway.
MaterialTheme(colorScheme = AiAppColors) {
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
// Timed like the transcript times itself, and for the same reason:
// the frame's draw phase is where Compose's measurement lands, and
// a report saying "draw is high" cannot otherwise say whether the
// cost is the transcript or the chrome around it. The keyboard is
// the case that made it matter.
Modifier.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record(
"measure: the app root",
System.nanoTime() - started,
)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
DebugStats.record(
"place: the app root",
System.nanoTime() - placing,
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record(
"record: the app root",
System.nanoTime() - started,
)
}
.fillMaxSize()
.statusBarsPadding()
// The gesture strip at the bottom of most phones. Without it
// the send row sits under the swipe area, where a tap is as
// likely to navigate away as to press a button.
//
// No imePadding here, deliberately: applied at the root it
// resizes this whole box on every frame of the keyboard
// animation, which re-measures, re-places and re-records every
// screen's entire tree per frame. Each screen takes the
// keyboard itself, so the per-frame cost is scoped to what
// actually moves.
.navigationBarsPadding()
) {
AppRoot(settingsVersion, openRequest, shareRequest)
}
}
}
}
}
}
/** The one session the `bench` build ever shows -- BenchFixture's session id, nothing else. */
private fun benchSessionSummary() =
SessionSummary(
id = BenchFixture.SESSION_ID,
setup = "bench",
setupName = "bench",
provider = "bench",
title = "P0 benchmark",
model = null,
keepsOwnTranscript = false,
permissionMode = null,
effort = null,
takesEffort = false,
imported = false,
notify = false,
autoResume = false,
autoResumeMessage = "",
resumeAt = null,
cwd = null,
contextTokens = null,
maxImageEdge = null,
usageProvider = null,
status = "idle",
lastActivity = 0.0,
subagents = 0,
)
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
// lands here rather than in a second activity instance.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
/**
* The one place an incoming intent is sorted into what it means.
*
* Three things arrive this way -- a share from another app, and an `aiapp://` URI that is
* either an enrollment code or a notification naming a session. The URIs are told apart by host
* rather than by two entry points, so a further kind is a branch here.
*/
private fun handleIntent(intent: Intent?) {
intent ?: return
sharedContent(intent, shares + 1)?.let { shared ->
shares = shared.serial
shareRequest = shared
return
}
val uri = intent.data ?: return
val sessionId = notifiedSessionId(uri)
if (sessionId != null) {
opens++
openRequest = SessionOpenRequest(sessionId, opens)
return
}
val settings = parseEnrollmentUri(uri)
if (settings == null) {
Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show()
return
}
saveServerSettings(this, settings)
settingsVersion++
// Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the
// moment the old one stops being it.
NotificationService.sync(this)
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
}
}
@@ -1,153 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
/**
* The app's root: one title, and four views of the backend behind it.
*
* These were four screens reached by four words in a row under the title, and the row was already
* full. Tabs say the same thing in less space and say one more thing besides: that these are places
* to be rather than errands to run. Sessions, the machine's importable history, the models on it
* and the machines themselves are all *the same backend*, looked at four ways, and none is a step
* down from another. Settings still is, which is why it stays a pushed screen with its own Back.
*/
private enum class MainTab(val label: String) {
Sessions("Sessions"),
Import("Import"),
Models("Models"),
Setups("Setups"),
}
@Composable
fun MainScreen(
settings: ServerSettings,
reloadToken: Int,
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
share: ShareRequest? = null,
onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit,
) {
var tab by remember { mutableStateOf(MainTab.Sessions) }
var refreshToken by remember { mutableIntStateOf(0) }
// Coming back to the app asks again, on whichever tab is showing.
//
// What these four draw is a snapshot of a backend they are not connected to, so it is only as
// fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A
// phone that was away while the tunnel was down came back to "Couldn't reach the server"
// sitting at the top of a list the server would now answer for perfectly well. A stale failure
// is worse than a stale list: it is a claim about right now.
//
// Through the same token the Refresh button uses, so this is one instruction the tabs already
// understand. Not on the first entry: the tab composing already asks.
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
var opening = true
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
if (!opening) refreshToken++
opening = false
}
}
// A tab the app put over the list has to step back to it rather than fall through to the system
// default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled.
BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions }
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp),
) {
Text(
"AI Sessions",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
// Glyphs rather than the words they replaced: neither ever changes, both are read
// faster than they are spelled, and together they take the width that let the title
// keep its own line. They sit on the title's row because they act on the whole screen.
//
// Flush against each other: a glyph button carries its own padding, so two side by side
// already have two rings between their marks.
Row {
GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ })
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
}
}
// What is waiting to be attached, and what to do about it. Said here because the list below
// is where the choice is made, and a share that arrived with nothing on screen saying so
// would read as a tap that did nothing.
share?.let {
Text(
it.summary() + " -- open the session it belongs in.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.shapes.small,
)
.padding(12.dp),
)
}
// Primary rather than the plain TabRow, which is deprecated in favour of the two that say
// where they sit: these are the app's top-level destinations.
PrimaryTabRow(selectedTabIndex = tab.ordinal) {
MainTab.entries.forEach { entry ->
Tab(
selected = tab == entry,
onClick = { tab = entry },
text = { Text(entry.label) },
)
}
}
// Refreshing means "ask again about what I am looking at", so the button feeds the tab that
// is showing. The token from above means something else already changed what these show;
// the two are the same instruction, so they are summed rather than tracked apart.
val token = reloadToken + refreshToken
when (tab) {
MainTab.Sessions ->
SessionListScreen(
settings = settings,
reloadToken = token,
onOpen = onOpen,
onOpenSubagent = onOpenSubagent,
onSpawn = onSpawn,
)
MainTab.Import ->
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
}
}
}
@@ -1,667 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.semantics.CollectionInfo
import androidx.compose.ui.semantics.CollectionItemInfo
import androidx.compose.ui.semantics.collectionInfo
import androidx.compose.ui.semantics.collectionItemInfo
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalImageTransformer
import com.mikepenz.markdown.compose.LocalMarkdownAnimations
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownComponents
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import com.mikepenz.markdown.compose.LocalReferenceLinkHandler
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownDivider
import com.mikepenz.markdown.compose.elements.listDepth
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.NoOpImageTransformerImpl
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.markdownAnimations
import com.mikepenz.markdown.model.markdownDimens
import com.mikepenz.markdown.model.markdownPadding
import com.mikepenz.markdown.model.parseMarkdown
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.flavours.gfm.GFMElementTypes
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
/**
* [text] drawn as its pieces, one under the other; see [Piece].
*
* [live] is the reply still arriving, and two things are different for it. Its parse is incremental
* -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole
* message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is
* worth a great deal while every delta invalidates the message and worth nothing once it stops
* changing -- and it is not free: each layer is a layout node and a display list held for the life
* of the row, and live node count is what the transcript's per-frame cost scales with.
*/
@Composable
fun MarkdownText(
text: String,
replies: ParsedReplies,
modifier: Modifier = Modifier,
live: Boolean = false,
) {
val segments =
if (live) liveSegments(text)
else remember(text) { listOf(Segment(text, 0, replies.of(text), replies.piecesOf(text))) }
Column(modifier.fillMaxWidth()) {
var previous: Piece? = null
var previousSegment: Segment? = null
segments.forEachIndexed { at, segment ->
val nextContinues = segments.getOrNull(at + 1)?.continues == true
// Only the tail is still being written; a frozen segment is finished text that happens
// to sit in a live reply, and it takes its colours now.
MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) {
segment.pieces.forEachIndexed { index, piece ->
val gap =
when {
previousSegment == null -> 0.dp
previousSegment !== segment ->
if (segment.continues) 0.dp else BLOCK_SPACING
else -> gapBefore(previous, piece)
}
// Keyed by where the piece starts in the message rather than by its position in
// this column, so a delta landing in the last block leaves every other piece's
// composition alone -- and a block keeps its key when it freezes.
key(segment.start, piece) {
MarkdownPiece(
segment.parse,
segment.text,
piece,
Modifier.padding(top = gap)
.then(if (live) Modifier.graphicsLayer() else Modifier)
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record(
"record: one block",
System.nanoTime() - started,
)
},
continuesList = segment.continues && index == 0,
listContinues = nextContinues && index == segment.pieces.lastIndex,
)
}
previous = piece
previousSegment = segment
}
}
}
}
}
/**
* A stretch of a message with a parse of its own: the whole of a settled message, or one block, the
* finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins
* in the message. [continues] says the first piece is an item of the list the segment before it
* ended with, so the two draw as one list.
*/
private class Segment(
val text: String,
val start: Int,
val parse: State,
val pieces: List<Piece>,
val continues: Boolean = false,
)
/**
* The live reply's segments: parsed on the composing thread the first time the row is drawn, and
* incrementally off it for every delta afterwards.
*
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
* height, and the transcript above it collapses and springs back -- seen with five replies on
* screen at once, the whole conversation shrunk to fit a single screen.
*
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it
* already has until the new one lands, so there is never a frame without a height.
*/
@Composable
private fun liveSegments(text: String): List<Segment> {
val parsed = remember {
mutableStateOf(
DebugStats.timed("markdown parsed while composing") { LiveParse.whole(text) }
)
}
LaunchedEffect(text) {
if (parsed.value.text == text) return@LaunchedEffect
val previous = parsed.value
parsed.value =
withContext(Dispatchers.Default) {
DebugStats.timed("markdown reparsed while streaming") { previous.advanceTo(text) }
}
}
return parsed.value.segments
}
/**
* A reply still arriving, parsed a block at a time.
*
* Reparsing the whole message per delta was fine for a short reply and not for a long one: a
* twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran
* off the composing thread it was every core busy while the frame's own thread waited for one.
*
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished -- nothing appended later can reach back into it. So every block but the last is
* [frozen] with the parse that finished it, and only the tail is parsed again.
*
* A list is cut once more, at its last item, by the same reasoning one level down. Without this a
* reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut
* lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of
* a paragraph line once `-x` arrives.
*
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
* it. The link draws as its brackets until the reply settles and is parsed whole by [warm].
*/
private class LiveParse(
val text: String,
private val frozen: List<Segment>,
/** How much of [text] the frozen segments cover; the tail starts here. */
private val consumed: Int,
private val tail: Segment,
) {
val segments: List<Segment>
get() = frozen + tail
fun advanceTo(next: String): LiveParse {
// Anything but an append to what was frozen -- a message replaced, a stream reset -- starts
// over.
if (!next.regionMatches(0, text, 0, consumed)) return whole(next)
val tailText = next.substring(consumed)
val parse = parseMarkdown(tailText)
val all = pieces(parse)
val open = (parse as? State.Success)?.let { openPiece(it, all) }
if (open == null) {
return LiveParse(
next,
frozen,
consumed,
Segment(tailText, consumed, parse, all, tail.continues),
)
}
val done =
all.subList(0, all.indexOf(open))
.groupBy { it.block }
.values
.mapIndexed { at, pieces ->
Segment(
tailText,
consumed,
parse,
pieces,
continues = at == 0 && tail.continues,
)
}
// Cut at the start of the open piece's line rather than at the piece, so an indented item
// or block keeps the indentation the parse of the rest reads its nesting from.
val node =
parse.node.children[open.block].let {
if (open.item == Piece.WHOLE_BLOCK) it else it.listItems()[open.item]
}
val cut = tailText.lastIndexOf('\n', node.startOffset) + 1
val rest = tailText.substring(cut)
val restParse = parseMarkdown(rest)
return LiveParse(
next,
frozen + done,
consumed + cut,
Segment(rest, consumed + cut, restParse, pieces(restParse), continues = open.item > 0),
)
}
/**
* The piece of the tail still being written: the last item of a list of several, or the first
* piece of the last block when there is more than one. Null when nothing before it is finished.
*/
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
val last = all.lastOrNull() ?: return null
val lastBlockStart = all.indexOfFirst { it.block == last.block }
return when {
last.item > 0 && parse.node.children[last.block].listItems()[last.item].hasBegun -> last
lastBlockStart > 0 -> all[lastBlockStart]
else -> null
}
}
/** Whether a list item holds anything beyond its marker yet. */
private val ASTNode.hasBegun: Boolean
get() = children.any { it.type !in MARKER_TOKENS }
companion object {
fun whole(text: String): LiveParse {
val parse = parseMarkdown(text)
return LiveParse(text, emptyList(), 0, Segment(text, 0, parse, pieces(parse)))
}
private val MARKER_TOKENS =
setOf(
MarkdownTokenTypes.LIST_BULLET,
MarkdownTokenTypes.LIST_NUMBER,
MarkdownTokenTypes.WHITE_SPACE,
MarkdownTokenTypes.EOL,
)
}
}
/** One [piece] of [text], drawn on its own -- a unit of the transcript list. */
@Composable
fun MarkdownPiece(
text: String,
piece: Piece,
replies: ParsedReplies,
modifier: Modifier = Modifier,
) {
// Remembered so a message the flatten drew before [warm] reached it is parsed once here, not
// once per composition.
val parse = remember(text) { replies.of(text) }
MarkdownRoot(parse, replies) { MarkdownPiece(parse, text, piece, modifier) }
}
/**
* The renderer's own environment -- its colours, type scale, dimensions, component table and
* reference links -- around whatever draws pieces of [parse].
*
* The parsing is the library's: markdown is somebody else's specification, and a hand-written
* parser would get the edge cases wrong one case at a time. So is the environment: the element
* composables its dispatch reaches read these locals, and providing them once here is what lets a
* piece be drawn anywhere -- in a message's column, or as one item of the transcript list.
*
* The locals are provided directly rather than through the renderer's `Markdown()` composable,
* which was the last of its composables on the hot path and was here only to provide them. So
* nothing between a piece and the screen is the library's but the leaf composables named in the
* component table.
*
* Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its
* own.
*
* [streaming] says this parse is the part of a reply still being written, which only the fences
* care about: lexing is proportional to how much code there is. Measured streaming a two-hundred-
* line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms --
* for colours on text being replaced as fast as they were computed. So a fence still being written
* is drawn plain and takes its colours when the block freezes.
*/
@Composable
private fun MarkdownRoot(
parse: State,
replies: ParsedReplies,
streaming: Boolean = false,
content: @Composable () -> Unit,
) {
if (parse !is State.Success) {
// Nothing below needs the environment; [MarkdownPiece] draws the words plainly.
content()
return
}
val body = MaterialTheme.typography.bodyLarge
CompositionLocalProvider(
LocalReferenceLinkHandler provides parse.referenceLinkHandler,
LocalMarkdownPadding provides markdownPadding(),
// Read by the renderer's own text composable, which no paragraph reaches any more, and by
// its checkbox. Provided so a path that does reach them draws no image rather than failing
// to compose.
LocalImageTransformer provides remember { NoOpImageTransformerImpl() },
LocalMarkdownAnimations provides markdownAnimations(),
LocalMarkdownColors provides
markdownColor(
text = MaterialTheme.colorScheme.onSurface,
dividerColor = MaterialTheme.colorScheme.outlineVariant,
// The dark surface every verbatim thing in this app sits on -- and the tool call
// above this reply, which now matches. `surfaceVariant` was exactly a card's own
// fill, so a fenced block inside a tool call had no background at all.
codeBackground = rawSurface,
// The same colour. Not drawn by the renderer as a span background but by
// [LinkedText] behind the text, so a selection lands on top of it -- see
// `appendCodeChip`.
inlineCodeBackground = rawSurface,
// The same tint a code block gets, rather than the renderer's 2%-alpha default: two
// adjacent tints that differ by a fiftieth read as one flat block on a phone.
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
),
LocalMarkdownTypography provides
markdownTypography(
// A ladder that starts near the body text and descends, because these are headings
// inside a chat message rather than the top of a document. The renderer's defaults
// are the Material *display* styles -- `#` came out at 57sp, bigger than this app's
// own screen titles. Every step is a different size, so two levels of nesting never
// draw the same.
h1 = MaterialTheme.typography.headlineSmall,
h2 = MaterialTheme.typography.titleLarge,
h3 = MaterialTheme.typography.titleMedium,
h4 = MaterialTheme.typography.titleSmall,
h5 = MaterialTheme.typography.labelMedium,
h6 = MaterialTheme.typography.labelSmall,
text = body,
paragraph = body,
ordered = body,
bullet = body,
list = body,
table = body,
// Code in a monospace face, in the ordinary text colour. The face and the tinted
// background are what say "this is code"; colour is not, and it used to be green --
// the palette's colour for a *literal*. A block of code is not a literal, and
// painting all of it green said the whole block was one. Where a literal really
// does appear inside code, what should colour it is a syntax highlighter.
code =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
),
inlineCode =
body.copy(
fontFamily = FontFamily.Monospace,
// Unspecified so an inline span keeps the size of the line it sits in.
fontSize = TextUnit.Unspecified,
color = MaterialTheme.colorScheme.onSurface,
),
textLink =
TextLinkStyles(
style =
body
.copy(
color = linkColor,
textDecoration = TextDecoration.Underline,
)
.toSpanStyle()
),
),
LocalMarkdownDimens provides
markdownDimens(
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
// the default a fifth of the narrowest column went on space rather than on words.
tableCellPadding = 8.dp,
// What a column narrows to before the table starts scrolling sideways instead. It
// is the floor, not the width: a table with room to spare spreads across it.
//
// Down from the renderer's 160dp, and the number is a measurement rather than a
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
// makes even a three-column table scroll, while 136dp fits three across the phone
// this app is read on. Four and up still scroll, which is the right answer for
// genuinely too many columns. This is the widest minimum that keeps three on
// screen.
tableCellWidth = 136.dp,
),
LocalMarkdownComponents provides
markdownComponents(
// The m3 renderer's own default, restored: supplying `components` at all replaces
// the whole set, and this is the only member the Material layer overrides.
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
// Everything that draws a run of text, so a link is a span rather than a node --
// see [LinkedText]. Setext headings take the same styles as `#` and `##`.
text = { LinkedText(it, it.typography.text) },
paragraph = { LinkedText(it, it.typography.paragraph) },
heading1 = { LinkedHeading(it, it.typography.h1) },
heading2 = { LinkedHeading(it, it.typography.h2) },
heading3 = { LinkedHeading(it, it.typography.h3) },
heading4 = { LinkedHeading(it, it.typography.h4) },
heading5 = { LinkedHeading(it, it.typography.h5) },
heading6 = { LinkedHeading(it, it.typography.h6) },
setextHeading1 = { LinkedHeading(it, it.typography.h1) },
setextHeading2 = { LinkedHeading(it, it.typography.h2) },
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so
// they draw like the top-level ones the transcript cuts into items.
orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
table = { LinkedTable(it.content, it.node, it.typography.table) },
// Code is highlighted the way a tool call's input is; see [CodeFence].
codeFence = {
CodeFence(it.content, it.node, it.typography.code, replies, streaming)
},
codeBlock = {
CodeBlock(it.content, it.node, it.typography.code, replies, streaming)
},
),
content = content,
)
}
/**
* A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need.
*
* Each column has a floor, so the table is at least columns-times-floor wide; narrower than the
* room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The
* renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one
* layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal
* scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after
* it reads the minimum back and sizes the rows to the larger of that and the floor.
*/
@Composable
private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
val dimens = LocalMarkdownDimens.current
val colors = LocalMarkdownColors.current
val columns =
remember(node) {
node.findChildOfType(GFMElementTypes.HEADER)?.children?.count {
it.type == GFMTokenTypes.CELL
} ?: 0
}
val rows = remember(node) { node.children.count { it.type == GFMElementTypes.ROW } + 1 }
val floor = dimens.tableCellWidth * columns
Column(
Modifier.background(colors.tableBackground, RoundedCornerShape(dimens.tableCornerSize))
.semantics { collectionInfo = CollectionInfo(rowCount = rows, columnCount = columns) }
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.layout { measurable, constraints ->
val width = maxOf(constraints.minWidth, floor.roundToPx())
val placeable =
measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
layout(width, placeable.height) { placeable.place(0, 0) }
}
) {
var rowIndex = 1
node.children.forEach { child ->
when (child.type) {
GFMElementTypes.HEADER -> LinkedTableRow(content, child, style, rowIndex = 0)
GFMElementTypes.ROW -> LinkedTableRow(content, child, style, rowIndex = rowIndex++)
GFMTokenTypes.TABLE_SEPARATOR -> MarkdownDivider()
}
}
}
}
/**
* One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText].
*
* The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means
* most of a table is simply not readable: an elided cell looks like a short one, so a table of
* measurements reads as a table of plausible shorter measurements. And they draw a link in a cell
* as its own layout node, the cost [LinkedText] exists to avoid.
*
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
* beside a one-line one centred the short one against the middle of the tall one. What the wrapping
* does *not* do is make a wide table fit; [LinkedTable] scrolls it instead.
*
* The semantics are the renderer's: each cell is an item of the table's collection.
*/
@Composable
private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) {
val padding = LocalMarkdownDimens.current.tableCellPadding
val header = rowIndex == 0
val cellStyle = if (header) style.copy(fontWeight = FontWeight.Bold) else style
Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
row.children
.filter { it.type == GFMTokenTypes.CELL }
.forEachIndexed { column, cell ->
LinkedText(
content,
cell,
cellStyle,
Modifier.padding(padding).weight(1f).semantics {
if (header) heading()
collectionItemInfo =
CollectionItemInfo(
rowIndex = rowIndex,
rowSpan = 1,
columnIndex = column,
columnSpan = 1,
)
},
)
}
}
}
/**
* Replies parsed before the row that draws them is composed.
*
* Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much
* was written. Measured against a real Claude Code transcript on the emulator, one message took
* **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first
* tuned on -- so a page of history landing composed several rows that each stalled the frame.
*
* Nothing here changes what a row does when it has no answer waiting: it parses inline, because a
* row measured at nothing before its real height collapses the transcript above it. The point is
* only that by the time the reader scrolls to a row, the answer is usually already made.
*
* A miss is not stored, and that is what bounds this: the map holds one entry per message a page
* warmed, so a reply still streaming cannot fill it with hundreds of copies of itself.
*/
@Stable
class ParsedReplies {
private val parsed = ConcurrentHashMap<String, State>()
/**
* How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per
* fold, and walking the tree again each time is proportional to the message.
*/
private val pieces = ConcurrentHashMap<String, List<Piece>>()
/**
* How each message divides into prose and memory notes, cached for the same reason: the regex
* scan behind [messageParts] is proportional to the message.
*/
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
private val chunks = ConcurrentHashMap<String, List<String>>()
/**
* Each fence's coloured text, keyed by its language and code.
*
* Beside the parses for the same reason and at the same cost: lexing is proportional to how
* much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator --
* and a lazy list drops the composition of a block that scrolls away, so a `remember` inside
* the fence paid that again every time the reader came back to it. Six times in one scroll,
* measured.
*/
private val highlights = ConcurrentHashMap<String, AnnotatedString>()
private val ready = ConcurrentHashMap.newKeySet<String>()
/** The pieces of [text], from its parse -- made now if [warm] has not made it. */
fun piecesOf(text: String): List<Piece> =
pieces.computeIfAbsent(text) {
DebugStats.timed("markdown cut into pieces") { pieces(of(it)) }
}
/** How a long user message divides into slices; cached for the same reason as [piecesOf]. */
fun chunksOf(text: String): List<String> =
chunks.computeIfAbsent(text) {
DebugStats.timed("user message cut into slices") { userChunks(it) }
}
/**
* Whether [warm] has made everything drawing [text] as pieces will look up.
*
* What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole
* message and the flatten runs on the composing thread, so a reply not marked yet stays whole
* until the screen has warmed it. An explicit mark rather than a peek into the parse cache,
* because a message with memory notes is warmed as its *parts*: nothing ever parses its full
* text, and inferring readiness from the cache left exactly that message unsplittable forever.
*/
fun splitReady(text: String): Boolean = text in ready
/** The other half of [splitReady]; [warm] calls it once a message's parses exist. */
fun markSplitReady(text: String) {
ready.add(text)
}
fun partsOf(text: String): List<MessagePart> =
parts.computeIfAbsent(text) {
DebugStats.timed("message cut into parts") { messageParts(it) }
}
/**
* [code] coloured for [language] -- the answer made ahead, or one made now. The key carries the
* language, because the same code lexes differently under two of them.
*/
fun highlighted(code: String, language: Language?): AnnotatedString =
if (language == null) AnnotatedString(code)
else highlights.computeIfAbsent("$language\n$code") { highlight(code, language) }
/** The parse of [text] -- the one made ahead, or one made now. */
fun of(text: String): State =
parsed[text]?.also { DebugStats.count("markdown ready") }
?: DebugStats.timed("markdown parsed while composing") { parseMarkdown(text) }
/**
* Parses whatever is not held yet. Call off the composing thread; that is the whole point.
*
* Suspending, and yielding between messages, because "off the composing thread" is not the same
* as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in
* a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every
* core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th
* percentile.
*/
suspend fun warm(texts: List<String>) {
texts.forEach { text ->
val parse =
parsed.computeIfAbsent(text) {
DebugStats.timed("markdown warmed") { parseMarkdown(it) }
}
// The fences too, and here rather than in a pass of its own: they are found in the
// parse this just made, and lexing one is the same kind of cost as parsing the message
// it is in.
fences(parse).forEach { (code, language) -> highlighted(code, language) }
}
}
/** Everything these described is gone; see [ParsedReplies]. */
fun clear() {
parsed.clear()
pieces.clear()
parts.clear()
chunks.clear()
highlights.clear()
ready.clear()
}
}
@@ -1,320 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.node.Ref
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import com.mikepenz.markdown.annotator.AnnotatorSettings
import com.mikepenz.markdown.annotator.annotatorSettings
import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.utils.getUnescapedTextInNode
import com.mikepenz.markdown.utils.resolveImageAlt
import com.mikepenz.markdown.utils.resolveImageLink
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
/**
* A paragraph, heading or bare text whose links are spans of the text rather than nodes of their
* own.
*
* Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable,
* hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation
* per link. Measured on the emulator against the same paragraphs with each link replaced by its
* label and address as plain words -- *more* text, the same gestures -- the linked version cost
* five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time.
*
* Here a link is the link colour and underline, a string annotation carrying its address, and one
* tap detector for the whole text that asks the layout which character was under the finger. What
* that gives up is a link being its own accessibility node with a pressed state; the app's link
* style never defined a pressed style, so nothing visible changes.
*
* Every block the renderer dispatches through its component table comes here, and so does every
* table cell. Reference-style links are the one kind still drawn the renderer's way.
*
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
* model put something. The link says what was there and where, and opens it.
*/
@Composable
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
LinkedText(model.content, model.node, style)
}
/**
* A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or
* `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it
* does not know, so handed the heading node itself it draws an empty line.
*/
@Composable
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
val words =
model.node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT)
?: model.node.findChildOfType(MarkdownTokenTypes.SETEXT_CONTENT)
?: model.node
LinkedText(model.content, words, style, Modifier.semantics { heading() })
}
/** The inline content of [node] within [content], drawn as [LinkedText] describes. */
@Composable
fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modifier = Modifier) {
val settings = plainLinkSettings()
val text =
remember(content, node, style) {
content.buildMarkdownAnnotatedString(node, style, settings)
}
val uriHandler = LocalUriHandler.current
val onPlainTap = LocalMarkdownTap.current
val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour.
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
val chips = remember(text) { text.getStringAnnotations(CODE_CHIP, 0, text.length) }
val chipColor = LocalMarkdownColors.current.inlineCodeBackground
// Filled in by `onTextLayout`, which runs in the layout phase, so the draw of the same frame
// finds it set -- no state needed, and a relayout redraws the node anyway.
val chipFills = remember { Ref<List<Rect>>() }
val chipFill =
if (chips.isEmpty()) Modifier
else
Modifier.drawBehind {
chipFills.value?.forEach { drawRect(chipColor, it.topLeft, it.size) }
}
BasicText(
text = text,
modifier =
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second
// one has to be answered from inside the text rather than left to the card.
modifier.then(chipFill).pointerInput(text, onPlainTap) {
awaitEachGesture {
// Unconsumed is not required: something outside may already be tracking this
// press, and it is still the press that may land on a link.
awaitFirstDown(requireUnconsumed = false)
// A tap and nothing else. Null when the gesture became somebody else's -- a
// scroll, or a press held past the long-press timeout, which is how a selection
// starts. The timeout is the load-bearing half: without it a press held for a
// second and released was still an up with nothing consumed, so holding a peer
// message to select from it shut the card instead.
val up =
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation()
} ?: return@awaitEachGesture
val url = text.linkAt(layout.value, up.position)
when {
url != null -> {
up.consume()
uriHandler.openUri(url)
}
onPlainTap != null -> {
up.consume()
onPlainTap()
}
}
}
},
style = style,
color = { color },
onTextLayout = {
layout.value = it
chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) }
},
)
}
/**
* What a tap on markdown text means when it lands on no link -- shutting the card it is drawn in,
* usually -- or null where a plain tap means nothing.
*
* A composition local because there is nowhere else to put it. The paragraphs of a message are
* composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours
* to pass a parameter through.
*
* It exists because a pointer-input node over the glyphs takes the tap and the card's own click
* handler never sees it. Measured against an opened peer message: with a handler on the text --
* consuming or not -- a tap on its words did nothing at all, and with the handler removed the same
* tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless
* the words do the shutting.
*
* Provided as a value that outlives a recomposition, since a fresh lambda per composition would
* invalidate every paragraph reading it.
*/
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/**
* [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
* under it on every recomposition of the card.
*/
@Composable
fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit {
val latest = rememberUpdatedState(onTap)
return remember { { latest.value() } }
}
/**
* The address under [position], if a link's glyph is there rather than merely nearest to it.
*
* The layout answers with a caret, the boundary nearest the finger, so a tap on the right half of a
* glyph names the character after it; the glyph under the finger is the one on either side of that
* boundary whose box holds the point. Checked with the box rather than assumed, so a tap past the
* end of a line ending in a link opens nothing.
*/
private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? {
layout ?: return null
val caret = layout.getOffsetForPosition(position)
val glyph =
(caret - 1..caret).firstOrNull {
it in 0 until length && layout.getBoundingBox(it).contains(position)
} ?: return null
return getStringAnnotations(LINK_URL, glyph, glyph + 1).firstOrNull()?.item
}
private const val LINK_URL = "url"
/**
* Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does,
* but with no background of its own -- if it is a code span; false leaves anything else to the
* renderer.
*
* The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's
* background is part of the text's own drawing, and the text node draws the selection first and the
* glyphs over it, so a chip painted as a span background covered the selection: selecting a
* sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the
* text is under both, which is where a fenced block's box already is.
*/
private fun appendCodeChip(
builder: AnnotatedString.Builder,
content: String,
node: ASTNode,
settings: AnnotatorSettings,
): Boolean {
if (node.type != MarkdownElementTypes.CODE_SPAN) return false
builder.pushStringAnnotation(CODE_CHIP, "")
builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified))
builder.append(' ')
// The backticks are the first and last children.
builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings)
builder.append(' ')
builder.pop()
builder.pop()
return true
}
private const val CODE_CHIP = "code"
/**
* One box per line of the text [start] until [end] covers, in the layout's own coordinates.
*
* Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every
* line but the last, so a chip whose code wrapped left a full-width empty box behind on the line
* above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space
* stops being drawn -- the same rule the selection rectangle obeys, so the two agree.
*
* A run's extent is taken from the boxes of its first and last characters, which is exact while a
* line reads in one direction; mixed directions inside a code span would draw one box across the
* whole run, and code spans are code.
*/
private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> {
val rects = mutableListOf<Rect>()
for (line in getLineForOffset(start)..getLineForOffset(end - 1)) {
val from = maxOf(start, getLineStart(line))
val to = minOf(end, getLineEnd(line, visibleEnd = true))
if (from >= to) continue
val head = getBoundingBox(from)
val tail = getBoundingBox(to - 1)
rects +=
Rect(
left = minOf(head.left, tail.left),
top = minOf(head.top, tail.top),
right = maxOf(head.right, tail.right),
bottom = maxOf(head.bottom, tail.bottom),
)
}
return rects
}
/**
* The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip]
* for inline code. The annotator needs the settings to draw a link's label, and the settings hold
* the annotator, so the reference goes through a cell filled in once both exist.
*/
@Composable
private fun plainLinkSettings(): AnnotatorSettings {
val cell = remember { Ref<AnnotatorSettings>() }
val annotator = remember {
markdownAnnotator { content, node ->
appendPlainLink(this, content, node, cell.value!!) ||
appendCodeChip(this, content, node, cell.value!!)
}
}
return annotatorSettings(annotator = annotator).also { cell.value = it }
}
/**
* Appends [node] as a styled, annotated span if it is a link the renderer would otherwise emit a
* `LinkAnnotation` for, or an image it would place; false leaves anything else to the renderer.
*/
private fun appendPlainLink(
builder: AnnotatedString.Builder,
content: String,
node: ASTNode,
settings: AnnotatorSettings,
): Boolean {
val destination: String
/** The label's own inline nodes, when it has markup of its own to draw. */
var label: List<ASTNode>? = null
/** Plain words for the label; the address itself when there are none. */
var words: String? = null
when (node.type) {
MarkdownElementTypes.INLINE_LINK -> {
val text = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) ?: return false
destination =
node
.findChildOfType(MarkdownElementTypes.LINK_DESTINATION)
?.getUnescapedTextInNode(content)
?.removeSurrounding("<", ">") ?: return false
// The brackets are the first and last children of the label.
label = text.children.drop(1).dropLast(1)
}
MarkdownElementTypes.AUTOLINK ->
destination = node.getUnescapedTextInNode(content).removeSurrounding("<", ">")
GFMTokenTypes.GFM_AUTOLINK -> destination = node.getUnescapedTextInNode(content)
MarkdownElementTypes.IMAGE -> {
destination =
node.resolveImageLink(content, settings.referenceLinkHandler) ?: return false
words = node.resolveImageAlt(content)
}
else -> return false
}
builder.pushStringAnnotation(LINK_URL, destination)
builder.pushStyle(settings.linkTextSpanStyle.style ?: SpanStyle())
if (label != null) builder.buildMarkdownAnnotatedString(content, label, settings)
else builder.append(words ?: destination)
builder.pop()
builder.pop()
return true
}
@@ -1,244 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownComponents
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import com.mikepenz.markdown.compose.MarkdownElement
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.model.State
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.ast.getTextInNode
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
/**
* One drawable piece of a parsed message: a top-level block, or one item of a top-level list.
*
* The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and
* is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a
* hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on
* a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one
* message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are.
*
* Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a
* nested list are each one node whatever is inside them. A list is the one block that is not
* bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its
* items.
*
* A piece is an *address* into the message's one parse rather than a substring of it. Every piece
* is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and
* a reference definition at its foot still resolves the links above it.
*/
@Immutable
data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
companion object {
const val WHOLE_BLOCK = -1
}
}
/**
* The pieces of [parse], in reading order. Blank nodes between blocks are not pieces.
*
* A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
* message that drew as nothing would be a hole in the transcript with no sign of what fell out.
*/
fun pieces(parse: State): List<Piece> {
val success = parse as? State.Success ?: return listOf(Piece(0))
val out = ArrayList<Piece>()
success.node.children.forEachIndexed { at, node ->
when {
node.getTextInNode(success.content).isBlank() -> {}
node.isList -> repeat(node.listItems().size) { out += Piece(at, it) }
else -> out += Piece(at)
}
}
return out
}
/**
* The room above [piece] when it follows [previous] in the same message: none between two items of
* one list, whose own padding already separates them, and a block's gap otherwise. The first piece
* of a message takes the message's gap, which is the caller's to know.
*/
fun gapBefore(previous: Piece?, piece: Piece): Dp =
if (previous != null && previous.block == piece.block) 0.dp else BLOCK_SPACING
/** The gap between one block of a reply and the next, wherever a reply is drawn in pieces. */
val BLOCK_SPACING: Dp = 6.dp
/**
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme,
* the components and the reference links to the renderer's element composables.
*
* A whole block goes to the renderer's own dispatch with this app's component table. Only the list
* item is drawn directly, because a list item is the one piece the renderer has no element for.
*
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an
* item that is the first or last of its own parse but not of the list the reader sees keeps an
* inner item's padding, so nothing moves when the seam between segments does.
*/
@Composable
fun MarkdownPiece(
parse: State,
text: String,
piece: Piece,
modifier: Modifier = Modifier,
continuesList: Boolean = false,
listContinues: Boolean = false,
) {
if (parse !is State.Success) {
// The parser threw. Nothing else in the app has seen this happen; if it does, the words are
// still worth more than a blank.
Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
return
}
val node = parse.node.children[piece.block]
if (piece.item == Piece.WHOLE_BLOCK) {
Box(modifier) {
MarkdownElement(
node,
LocalMarkdownComponents.current,
parse.content,
includeSpacer = false,
)
}
} else {
val items = node.listItems()
MarkdownListItem(
content = parse.content,
list = node,
item = items[piece.item],
index = piece.item,
first = piece.item == 0 && !continuesList,
last = piece.item == items.lastIndex && !listContinues,
depth = 0,
modifier = modifier,
)
}
}
/**
* A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a
* list inside a quote, and the nested lists an item holds. Top-level lists never come here.
*/
@Composable
fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
val items = list.listItems()
Column(modifier) {
items.forEachIndexed { index, item ->
MarkdownListItem(
content,
list,
item,
index,
first = index == 0,
last = index == items.lastIndex,
depth = depth,
)
}
}
}
/**
* One item: its marker beside its content, laid out the way the renderer's own list does so that a
* list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first
* and last items, since there is no list column to carry it.
*
* The marker is drawn here rather than by a handler because it is the thing a reader might one day
* want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn.
*/
@Composable
private fun MarkdownListItem(
content: String,
list: ASTNode,
item: ASTNode,
index: Int,
first: Boolean,
last: Boolean,
depth: Int,
modifier: Modifier = Modifier,
) {
val padding = LocalMarkdownPadding.current
val typography = LocalMarkdownTypography.current
val components = LocalMarkdownComponents.current
// A task item's box sits right after the bullet: `- [ ] text`.
val checkbox = item.children.getOrNull(1)?.takeIf { it.type == GFMTokenTypes.CHECK_BOX }
Row(
modifier
.semantics { isTraversalGroup = true }
.fillMaxWidth()
.padding(
start = padding.listIndent * depth,
top = padding.listItemTop + if (first) padding.list else 0.dp,
bottom = padding.listItemBottom + if (last) padding.list else 0.dp,
)
) {
if (checkbox != null) {
components.checkbox(MarkdownComponentModel(content, checkbox, typography))
} else if (list.type == MarkdownElementTypes.ORDERED_LIST) {
Marker("${list.startNumber(content) + index}. ", typography.ordered)
} else {
Marker(BULLETS[depth % BULLETS.size], typography.bullet)
}
Column {
item.children.forEach { child ->
when (child.type) {
MarkdownTokenTypes.LIST_BULLET,
MarkdownTokenTypes.LIST_NUMBER,
GFMTokenTypes.CHECK_BOX -> {}
MarkdownElementTypes.ORDERED_LIST,
MarkdownElementTypes.UNORDERED_LIST -> MarkdownList(content, child, depth + 1)
else -> MarkdownElement(child, components, content, includeSpacer = false)
}
}
}
}
}
/** The marker in [listMarkerColor]; the renderer's styles carry no colour of their own. */
@Composable
private fun Marker(text: String, style: TextStyle) {
BasicText(text, style = style.copy(color = listMarkerColor))
}
/**
* The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a
* browser draws, so a nested list is told from its parent by the glyph as well as by the indent.
* Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check
* is the price of adding one here.
*/
private val BULLETS = listOf("", "", "")
internal val ASTNode.isList: Boolean
get() = type == MarkdownElementTypes.ORDERED_LIST || type == MarkdownElementTypes.UNORDERED_LIST
internal fun ASTNode.listItems(): List<ASTNode> = children.filter {
it.type == MarkdownElementTypes.LIST_ITEM
}
/** Where an ordered list counts from: the number its first item was written with. */
private fun ASTNode.startNumber(content: String): Int =
findChildOfType(MarkdownElementTypes.LIST_ITEM)
?.findChildOfType(MarkdownTokenTypes.LIST_NUMBER)
?.getTextInNode(content)
?.takeWhile(Char::isDigit)
?.toString()
?.toIntOrNull() ?: 1
@@ -1,450 +0,0 @@
package com.example.aiapp
/**
* Markdown read into the spans that carry a colour -- a ```markdown fence in a reply, and a `.md`
* file in the viewer.
*
* Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings:
* what a character means depends on where it sits. A `#` opens a heading at the start of a line and
* is an ordinary character three words in; a `*` opens emphasis only if something closes it on the
* same line. The token scanner cannot ask either question.
*
* Structure is read a line at a time and each line's prose left to right, so every decision is made
* inside one line -- except the two that are not. A fenced block is state carried forward, so an
* unclosed fence colours the rest of the text, which is what it looks like while somebody is
* writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot
* be anything else, and its header is the line before that -- the one place here that looks ahead.
*
* What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is
* one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by
* what came before. Colouring the wrong one as code is a mistake the reader cannot see.
*
* Like [scan], the spans come out ordered, non-overlapping and inside the text by construction.
*/
fun scanMarkdown(code: String): List<Span> = MarkdownScanner(code).run()
/** The characters an unordered list may be bulleted with. */
private const val BULLETS = "-*+"
/** The characters a thematic break, or a setext heading's underline, can be drawn with. */
private const val RULE_MARKERS = "-*_="
/** The characters that can open emphasis, strong emphasis or a strikethrough. */
private const val EMPHASIS = "*_~"
/** Characters that end a bare URL wherever they appear, and ones only trimmed off the end. */
private const val URL_STOPS = "<>\"'`|"
private const val URL_TRAILING = ".,:;!?"
private class MarkdownScanner(private val code: String) {
private val spans = ArrayList<Span>()
fun run(): List<Span> {
var at = 0
// The delimiter run that opened the fenced block we are inside, or null between them.
var fence: String? = null
// Whether the row above was part of a table, which is what makes this one a body row.
var table = false
while (at <= code.length) {
val end = lineEnd(at)
val open = fence
if (open != null) {
// The content and the closing line alike: a fence is one block of code, and its own
// delimiters belong to it the way a string's quotes belong to the string.
emit(at, end, Kind.STRING)
if (closesFence(at, end, open)) fence = null
} else {
val opened = opensFence(at, end)
fence = opened
if (opened != null) table = false else table = row(at, end, table)
}
if (end == code.length) break
at = end + 1
}
return spans
}
/** The end of the line beginning at [at]: the newline, or the end of the text. */
private fun lineEnd(at: Int): Int {
val newline = code.indexOf('\n', at)
return if (newline < 0) code.length else newline
}
/**
* One line that is not inside a fence, and whether the table it may be part of is still open.
*
* A table is recognised by its delimiter row, the only line of one that cannot be anything
* else. That row comes *after* the header it belongs to, so the header is found by looking one
* line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the
* document, which would mark the pipes in a shell command written in a paragraph.
*/
private fun row(start: Int, end: Int, table: Boolean): Boolean {
if (tableDelimiter(start, end)) {
emit(indented(start, end), end, Kind.MARK)
return true
}
val header = end < code.length && tableDelimiter(end + 1, lineEnd(end + 1))
if ((table || header) && hasPipe(start, end)) {
tableRow(start, end)
return true
}
structure(start, end)
return false
}
/** A line of nothing but pipes, dashes, alignment colons and space, with one of each needed. */
private fun tableDelimiter(start: Int, end: Int): Boolean {
var dashes = false
var pipes = false
for (at in indented(start, end) until end) {
when (code[at]) {
'-' -> dashes = true
'|' -> pipes = true
':',
' ',
'\t' -> {}
else -> return false
}
}
return dashes && pipes
}
private fun hasPipe(start: Int, end: Int): Boolean {
var at = start
while (at < end) {
if (code[at] == '\\') at += 2 else if (code[at] == '|') return true else at++
}
return false
}
/** A table row: the pipes are the structure, and what is between them is prose. */
private fun tableRow(start: Int, end: Int) {
var at = indented(start, end)
var cell = at
while (at < end) {
when (code[at]) {
'\\' -> at += 2
'|' -> {
inline(cell, at)
emit(at, at + 1, Kind.MARK)
at++
cell = at
}
else -> at++
}
}
inline(cell, end)
}
/**
* Spans, coalesced with the one before when they touch and agree. Worth doing here rather than
* leaving it to the caller: the line scanner emits per marker and per word, so a heading would
* otherwise arrive as a dozen abutting spans of one colour.
*/
private fun emit(start: Int, end: Int, kind: Kind) {
if (end <= start) return
val last = spans.lastOrNull()
if (last != null && last.kind == kind && last.end == start) {
spans[spans.size - 1] = Span(last.start, end, kind)
} else {
spans.add(Span(start, end, kind))
}
}
/** The first character of the line at or after [start] that is not indentation. */
private fun indented(start: Int, end: Int): Int {
var at = start
while (at < end && (code[at] == ' ' || code[at] == '\t')) at++
return at
}
/** The run of backticks or tildes that could open or close a fence on this line, or null. */
private fun fenceRun(start: Int, end: Int): IntRange? {
val at = indented(start, end)
if (at == end) return null
val marker = code[at]
if (marker != '`' && marker != '~') return null
var run = at
while (run < end && code[run] == marker) run++
return if (run - at >= 3) at until run else null
}
/** Draws an opening fence line and answers its delimiter, or null if this is not one. */
private fun opensFence(start: Int, end: Int): String? {
val run = fenceRun(start, end) ?: return null
emit(run.first, run.last + 1, Kind.STRING)
// The info word is what the fence is a fence *of*, which is metadata about the block rather
// than part of it.
emit(indented(run.last + 1, end), end, Kind.METADATA)
return code.substring(run.first, run.last + 1)
}
/**
* Whether this line closes a fence opened by [open]: the same character, at least as many of
* them, and nothing else on the line -- so a longer run closes a shorter one and a line of
* backticks with a word after it does not close anything.
*/
private fun closesFence(start: Int, end: Int, open: String): Boolean {
val run = fenceRun(start, end) ?: return false
if (code[run.first] != open[0] || run.last + 1 - run.first < open.length) return false
return indented(run.last + 1, end) == end
}
/** One ordinary line: what its opening characters make it, and then its prose. */
private fun structure(start: Int, end: Int) {
var at = indented(start, end)
// Quote markers come before everything else and can be several deep, and what follows one
// is an ordinary line again -- a heading inside a quote is still a heading.
while (at < end && code[at] == '>') {
at++
emit(at - 1, at, Kind.MARK)
at = indented(at, end)
}
if (at == end) return
if (heading(at, end) || thematicBreak(at, end)) return
inline(bullet(at, end), end)
}
/** `#` to `######` and a space. Without the space it is a word beginning with a hash. */
private fun heading(start: Int, end: Int): Boolean {
var at = start
while (at < end && code[at] == '#') at++
val depth = at - start
if (depth !in 1..6) return false
if (at < end && code[at] != ' ' && code[at] != '\t') return false
emit(start, end, Kind.KEYWORD)
return true
}
/**
* A line made of one repeated rule character and nothing else.
*
* `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a
* setext heading. The two are the same line to look at and mean the same thing to a reader, so
* they get one appearance rather than a lookback. One `=` is enough because a setext underline
* may be a single character; a break needs three, which keeps a `- ` bullet out of here.
*/
private fun thematicBreak(start: Int, end: Int): Boolean {
val marker = code[start]
if (marker !in RULE_MARKERS) return false
var seen = 0
for (at in start until end) {
val character = code[at]
if (character == marker) seen++ else if (!character.isWhitespace()) return false
}
if (seen < if (marker == '=') 1 else 3) return false
emit(start, end, Kind.MARK)
return true
}
/** Draws a list marker if the line opens with one, and answers where the item's text starts. */
private fun bullet(start: Int, end: Int): Int {
val marker = code[start]
if (marker in BULLETS && spaceOrEnd(start + 1, end)) {
emit(start, start + 1, Kind.MARK)
return indented(start + 1, end)
}
var digits = start
while (digits < end && code[digits].isDigit()) digits++
val delimiter = code.getOrNull(digits)
if (
digits > start && (delimiter == '.' || delimiter == ')') && spaceOrEnd(digits + 1, end)
) {
emit(start, digits + 1, Kind.MARK)
return indented(digits + 1, end)
}
return start
}
private fun spaceOrEnd(at: Int, end: Int) = at >= end || code[at] == ' ' || code[at] == '\t'
/**
* The inline forms, left to right.
*
* Every branch answers a position strictly after [start] of its call, so this terminates
* whether or not the form it was looking at turned out to be one.
*/
private fun inline(start: Int, end: Int) {
var at = start
while (at < end) {
val character = code[at]
at =
when {
// A backslash takes the character after it out of the running entirely, which
// is how `\*` stays an asterisk rather than opening emphasis.
character == '\\' -> at + 2
character == '`' -> codeSpan(at, end)
character == '[' -> link(at, at, end)
character == '!' && code.getOrNull(at + 1) == '[' -> link(at, at + 1, end)
character == '<' -> autolink(at, end)
character in EMPHASIS -> emphasis(at, end)
else -> url(at, end) ?: (at + 1)
}
}
}
/**
* `` `code` ``, closed by a run of exactly as many backticks as opened it. That count is what
* lets a span hold a backtick of its own, and why the search skips over a shorter or longer run
* rather than stopping at the first backtick.
*/
private fun codeSpan(start: Int, end: Int): Int {
var open = start
while (open < end && code[open] == '`') open++
val ticks = open - start
var at = open
while (at < end) {
if (code[at] != '`') {
at++
continue
}
var close = at
while (close < end && code[close] == '`') close++
if (close - at == ticks) {
emit(start, close, Kind.STRING)
return close
}
at = close
}
// Nothing closes it on this line, so those were ordinary backticks.
return open
}
/**
* `[text](destination)`, and the same with a leading `!` for an image.
*
* The text is drawn as prose -- it is what the reader reads -- so only the brackets around it
* are marked, and the destination is metadata. A `[text]` with no destination after it is left
* plain, because that is what a reference link and a bracketed aside look like.
*/
private fun link(start: Int, bracket: Int, end: Int): Int {
var depth = 0
var close = bracket
while (close < end) {
when (code[close]) {
'\\' -> close++
'[' -> depth++
']' -> {
depth--
if (depth == 0) break
}
}
close++
}
if (close >= end) return start + 1
val destination = close + 1
if (code.getOrNull(destination) != '(') return start + 1
val paren = code.indexOf(')', destination)
if (paren < 0 || paren >= end) return start + 1
emit(start, bracket + 1, Kind.MARK)
inline(bracket + 1, close)
emit(close, destination, Kind.MARK)
emit(destination, paren + 1, Kind.METADATA)
return paren + 1
}
/**
* `<https://example.com>` and `<name@example.com>`, drawn as the destination they are.
*
* The angle brackets have to hold no whitespace and something that makes an address of it -- a
* scheme's colon or an at sign -- which is what keeps an HTML tag out.
*/
private fun autolink(start: Int, end: Int): Int {
var at = start + 1
var addressed = false
while (at < end) {
val character = code[at]
if (character.isWhitespace() || character == '<') return start + 1
if (character == '>') {
if (!addressed) return start + 1
emit(start, at + 1, Kind.METADATA)
return at + 1
}
if (character == ':' || character == '@') addressed = true
at++
}
return start + 1
}
/**
* A bare `scheme://…` written in prose, or null if one does not start here.
*
* A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry.
*
* Where it ends is the part worth stating: the sentence's punctuation is not the address, so a
* trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the
* URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a
* table cell must not swallow the cell's edge.
*/
private fun url(start: Int, end: Int): Int? {
if (start > 0 && isWord(code[start - 1])) return null
var scheme = start
while (scheme < end && code[scheme].isLetter()) scheme++
if (scheme == start || !code.startsWith("://", scheme)) return null
val body = scheme + 3
var at = body
var openers = 0
var closers = 0
while (at < end && !code[at].isWhitespace() && code[at] !in URL_STOPS) {
if (code[at] == '(') openers++ else if (code[at] == ')') closers++
at++
}
while (at > body) {
val last = code[at - 1]
if (last in URL_TRAILING) at--
else if (last == ')' && closers > openers) {
closers--
at--
} else break
}
if (at == body) return null
emit(start, at, Kind.METADATA)
return at
}
/**
* `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all -- which is how the
* token scanner draws a string: the quotes are part of the thing.
*
* The two guards keep this off code that happens to be in a paragraph: the opener must be
* followed by something to emphasise and the closer preceded by something emphasised, so `a * b
* * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start
* or end inside a word, or every `snake_case_name` would be half emphasised.
*/
private fun emphasis(start: Int, end: Int): Int {
val marker = code[start]
var open = start
while (open < end && code[open] == marker) open++
val length = open - start
if (marker == '~' && length != 2) return open
if (length > 3) return open
if (open == end || code[open].isWhitespace()) return open
if (marker == '_' && start > 0 && isWord(code[start - 1])) return open
var at = open
while (at < end) {
if (code[at] == '\\') {
at += 2
continue
}
if (code[at] != marker) {
at++
continue
}
var close = at
while (close < end && code[close] == marker) close++
val finish = at + length
if (
close - at >= length &&
!code[at - 1].isWhitespace() &&
!(marker == '_' && finish < end && isWord(code[finish]))
) {
emit(start, finish, Kind.LITERAL)
return finish
}
at = close
}
return open
}
}
private fun isWord(character: Char) = character.isLetterOrDigit() || character == '_'
@@ -1,162 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* An assistant's reply, with anything it says it remembered drawn as a note rather than as markup.
*
* Claude Code marks a sentence that came from its stored memory by wrapping it in `<cc-memory
* filenames="...">`. Markdown has nothing to say about that, so it arrived on screen as literal
* angle brackets in the middle of a sentence -- which reads as the model having emitted broken
* HTML. It is really the opposite: a claim about where something came from, and "I was told this
* before" and "I worked this out just now" are different things the reader cannot otherwise tell
* apart.
*
* A tag that has not finished arriving is left alone: a half-written marker is not a marker yet.
*/
@Composable
fun AssistantMessage(
text: String,
replies: ParsedReplies,
/** Which notes are open, by [MessagePart.Remembered.text] -- see [MemoryNote]. */
openNotes: Set<String>,
onToggleNote: (String) -> Unit,
modifier: Modifier = Modifier,
live: Boolean = false,
) {
DebugStats.count("message composed")
val parts = remember(text) { messageParts(text) }
val only = parts.singleOrNull()
if (only is MessagePart.Prose) {
MarkdownText(only.text, replies, modifier, live)
return
}
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
parts.forEach { part ->
when (part) {
is MessagePart.Prose -> MarkdownText(part.text, replies, live = live)
is MessagePart.Remembered ->
MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) }
}
}
}
}
/**
* The pieces [AssistantMessage] draws, which is [splitMemoryNotes] with one correction.
*
* A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed
* prose part made while looking for them -- inspecting a message must not change it. That belongs
* here rather than at the places that need the answer, because [warm] has to name the same strings
* the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports.
*
* Public because [transcriptUnits] flattens settled replies into the same parts; go through
* [ParsedReplies.partsOf] on any path that runs per fold or per page.
*/
fun messageParts(text: String): List<MessagePart> {
val parts = splitMemoryNotes(text)
return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts
}
/**
* One sentence the model attributed to a memory file, closed until somebody asks.
*
* Closed by default, like a tool call and a peer message and for the same reason: it is not part of
* what was said to the reader, it is a note about where a claim came from. Left open it breaks the
* reply in half around a card, and these arrive several to a message.
*
* What stays visible is which file it came from, because that is the whole of what the note claims
* and the part a reader scanning for "why does it think that" is looking for.
*
* Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to
* still be open on the way back, and a card that remembered for itself would forget the moment the
* list stopped composing it.
*/
@Composable
fun MemoryNote(
note: MessagePart.Remembered,
replies: ParsedReplies,
expanded: Boolean,
onToggle: () -> Unit,
) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
// Named, not just tinted: a colour can say "this one is different", but it cannot say
// what kind of different, and "recalled from a file" is a difference in kind.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (note.files.size == 1) "remembered from ${note.files[0]}"
else "remembered from ${note.files.joinToString(", ")}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!expanded) {
Spacer(Modifier.width(8.dp))
Text(
note.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
// The head, not the tail: a sentence is identified by how it opens.
overflow = TextOverflow.Ellipsis,
)
}
}
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
if (expanded) {
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
}
}
}
}
}
/** One piece of a reply: ordinary prose, or a sentence attributed to a memory file. */
sealed class MessagePart {
/** The markdown this piece is drawn from. */
abstract val text: String
data class Prose(override val text: String) : MessagePart()
data class Remembered(override val text: String, val files: List<String>) : MessagePart()
}
private val MEMORY_NOTE =
Regex("""<cc-memory\s+filenames="([^"]*)"\s*>(.*?)</cc-memory>""", RegexOption.DOT_MATCHES_ALL)
/**
* Splits [text] into prose and memory notes, in order. Always returns at least one part, so a
* message with no notes is one piece of prose and costs nothing extra to draw.
*/
fun splitMemoryNotes(text: String): List<MessagePart> {
val parts = mutableListOf<MessagePart>()
var at = 0
for (match in MEMORY_NOTE.findAll(text)) {
val before = text.substring(at, match.range.first)
if (before.isNotBlank()) parts += MessagePart.Prose(before.trim())
val files = match.groupValues[1].split(",").map { it.trim() }.filter { it.isNotEmpty() }
parts += MessagePart.Remembered(match.groupValues[2].trim(), files)
at = match.range.last + 1
}
val rest = text.substring(at)
if (rest.isNotBlank() || parts.isEmpty()) parts += MessagePart.Prose(rest.trim())
return parts
}
@@ -1,32 +0,0 @@
package com.example.aiapp
/**
* What a session with no model of its own is called, in the button and in the list it opens.
*
* One constant rather than a literal in each place, because the two have to agree: a picker whose
* options cannot say every state its button can display is one you can leave and not get back to.
* It is also the Claude CLI's own word for "whatever is configured".
*/
const val DEFAULT_MODEL = "default"
/**
* A model's name as a person reads it.
*
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude-
* haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using"
* and far too long for a button in a row that also holds Stop and Send.
*
* So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which
* is the same on every model this app can show, and the release date, which distinguishes builds of
* one model rather than one model from another. Anything that does not look like that is returned
* untouched.
*
* A display decision, not a correction: the full name is what the session reports.
*/
fun modelLabel(model: String?): String {
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
}
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
private val DATED_SUFFIX = Regex("""-\d{8}$""")
@@ -1,374 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Models on the backend, and HuggingFace to get more from.
*
* Everything here is the server's state rather than this screen's: what is downloaded, and what is
* downloading, are the same answers on every enrolled device, and a download started here keeps
* going when this screen closes.
*/
@Composable
fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<Models>>(LoadState.Loading) }
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<LoadState<List<RemoteRepo>>?>(null) }
var openRepo by remember { mutableStateOf<String?>(null) }
var repoFiles by remember { mutableStateOf<LoadState<List<RemoteFile>>?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchModels(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// Polled rather than pushed: a download belongs to the machine, not to any session, so it has
// no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the
// loop with a read now rather than leaving the reader watching for a second and a half.
LaunchedEffect(reloadToken) {
while (true) {
reload()
delay(1500)
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search HuggingFace") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
TextButton(
enabled = query.isNotBlank(),
onClick = {
openRepo = null
results = LoadState.Loading
scope.launch {
results =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(searchModels(settings, query))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
},
) {
Text("Search")
}
Spacer(Modifier.height(8.dp))
LazyColumn(Modifier.fillMaxSize()) {
when (val current = state) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(current.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded -> {
if (current.value.downloads.isNotEmpty()) {
item { SectionLabel("Downloading") }
uniqueItems(current.value.downloads, key = { it.key + it.run }) { download
->
DownloadCard(download) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
cancelDownload(settings, download.key)
}
}
.exceptionOrNull()
?.message
}
}
}
}
item { SectionLabel("On the backend") }
if (current.value.local.isEmpty()) {
item {
Text(
"None yet. Search above to find one.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
uniqueItems(current.value.local, key = { it.key }) { model ->
LocalModelCard(model) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
deleteModel(settings, model.key)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
results?.let { found ->
item { SectionLabel("HuggingFace") }
when (found) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded ->
uniqueItems(found.value, key = { it.id }) { repo ->
val open = openRepo == repo.id
RepoRow(repo, expanded = open) {
if (open) {
openRepo = null
} else {
openRepo = repo.id
repoFiles = LoadState.Loading
scope.launch {
repoFiles =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(
fetchRepoFiles(settings, repo.id)
)
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
}
// Inside the expanded repository's own item rather than as a section
// after the list: drawn after every card, a repository's files read as
// belonging to whichever card happened to be last.
if (open) {
when (val files = repoFiles) {
null -> {}
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error ->
Text(files.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
Column {
val busy =
(state as? LoadState.Loaded)
?.value
?.downloads
.orEmpty()
.filter { it.state == "running" }
.map { it.key }
.toSet()
files.value.forEach { file ->
RepoFileRow(
file,
downloading = "${repo.id}/${file.path}" in busy,
) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
startDownload(
settings,
repo.id,
file.path,
)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
}
}
}
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Spacer(Modifier.height(12.dp))
Text(text, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(4.dp))
}
@Composable
private fun DownloadCard(download: Download, onCancel: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(download.file, style = MaterialTheme.typography.titleSmall)
Text(
download.repo,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. The server sends no total when it was
// never told one, and a bar drawn from a guess is worse than one that admits it is
// counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say the
// opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
Text(
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
style = MaterialTheme.typography.bodySmall,
)
} else {
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text(
"${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall,
)
}
download.error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row {
Text(
download.state,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
if (download.state == "running") {
TextButton(onClick = onCancel) { Text("Cancel") }
}
}
}
}
}
@Composable
private fun LocalModelCard(model: LocalModel, onDelete: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(model.file, style = MaterialTheme.typography.titleSmall)
Text(
"${model.repo} · ${gigabytes(model.bytes)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onDelete) { Text("Delete") }
}
}
}
@Composable
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
"${repo.downloads} downloads · ${repo.likes} likes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
}
}
}
@Composable
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(file.path, style = MaterialTheme.typography.bodyMedium)
Text(
gigabytes(file.bytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
file.have -> "Downloaded"
downloading -> "Downloading"
else -> "Download"
}
)
}
}
}
private fun gigabytes(bytes: Long): String =
if (bytes >= 1_000_000_000) {
"%.2f GB".format(bytes / 1_000_000_000.0)
} else {
"%.0f MB".format(bytes / 1_000_000.0)
}
@@ -1,270 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* The icons the app draws, as glyphs in a Nerd Fonts subset rather than as vector assets.
*
* Drawing them as *text* is what makes them cheap: an icon beside a line of text wants that line's
* size, colour and baseline, and a `Text` gets all three for free where an `Icon` needs each one
* set and kept in step by hand.
*
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
* grounds that a system font may not have the glyph. That objection is about *relying* on a system
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
* is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
* the script did not subset is a glyph that silently isn't there.
*
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
* That is what makes two icons the same size without either being given a size: the proportional
* face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came
* out visibly different widths. [GLYPH_SIZE] carries the cost.
*
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
* Material Design codepoints. Those two must not drift. The script is copied rather than shared
* because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the
* point of subsetting is to ship only the codepoints one app draws.
*/
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
/** Nerd Fonts puts these in plane 15, so each is a surrogate pair. */
private fun glyph(codePoint: Int) = String(Character.toChars(codePoint))
/** `md-cog` -- settings for the thing it sits beside. */
val SETTINGS_GLYPH = glyph(0xF0493)
/** `md-refresh` -- ask the server again for whatever is on screen. */
val REFRESH_GLYPH = glyph(0xF0450)
/** `md-send` -- the filled paper plane: submit what is in the composer. */
val SEND_GLYPH = glyph(0xF048A)
/**
* `md-stop` -- a filled square: end the process behind this session.
*
* The square is what stop has meant since tape decks, and it is spent here on the thing that
* actually stops rather than on pausing. [PAUSE_GLYPH] is the turn; this is the session.
*/
val STOP_GLYPH = glyph(0xF04DB)
/**
* `md-pause` -- two bars: take the running turn away and leave the session there.
*
* The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what
* pressing it now would do to the process, and the three marks are the three answers. An interrupt
* ends a turn and nothing else, which is a pause, not a stop.
*/
val PAUSE_GLYPH = glyph(0xF03E4)
/** `md-play` -- start the process again, on the conversation it left. See [PAUSE_GLYPH]. */
val PLAY_GLYPH = glyph(0xF040A)
/**
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
*
* The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than
* starting one, and one glyph doing both jobs would promise something immediate and do something
* that waits.
*/
val QUEUE_GLYPH = glyph(0xF1163)
/** `md-close` -- take this off again: an attachment picked and not wanted. */
val CLOSE_GLYPH = glyph(0xF0156)
/** `md-arrow_left` -- back one level, to whatever this was opened from. */
val BACK_GLYPH = glyph(0xF004D)
/** `md-bell` -- the notifications this session is allowed to raise. */
val BELL_GLYPH = glyph(0xF009A)
/**
* `fa-line_chart` -- how much of the account's rate limits is gone.
*
* Font Awesome's rather than Material's, which is the one break in the family above: it was asked
* for by name, and Material's chart glyphs are a bare line where this one has its axes.
*/
val USAGE_GLYPH = glyph(0xF201)
/**
* `md-speedometer` -- what this session is costing to draw.
*
* A speedometer rather than a bug, because what it copies is a measurement rather than a fault
* report: it is as useful on a screen that feels fine, where the answer is that nothing is slow.
*/
val SPEED_GLYPH = glyph(0xF04C5)
/**
* `md-folder` -- the files on the machine this session runs on.
*
* The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and
* the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is
* what makes the button say where it leads.
*/
val FOLDER_GLYPH = glyph(0xF024B)
/** `md-file_outline` -- one file, in a listing beside the directories. */
val FILE_GLYPH = glyph(0xF0224)
/** `md-plus` -- make something here. dev-updater's codepoint as well. */
val PLUS_GLYPH = glyph(0xF0415)
/** `md-pencil` -- change what this file says, rather than only reading it. */
val EDIT_GLYPH = glyph(0xF03EB)
/**
* `md-content_save` -- write the edits back to the machine.
*
* The floppy disk, which is what save has meant for longer than most of the people reading it have
* been alive and is still the only mark anybody recognises for it.
*/
val SAVE_GLYPH = glyph(0xF0193)
/**
* The size an icon draws at beside a line of text.
*
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
* most 0.83 em of its point size, so the number was standing in for the headroom above the tallest
* one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every
* icon in the app up by a fifth.
*/
private val GLYPH_SIZE = 17.sp
/**
* The same measurement in dp: a glyph's em box is its point size, and a layout is laid out in dp.
*/
private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
/**
* The square a glyph button occupies: the mark, plus the same ring of padding on all four sides.
*
* The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to
* the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of
* its own. That is what it was: the box was the size of the mark (28dp) and the separation was
* bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the
* edge.
*
* 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has
* to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's
* own corners and beside a title it arrived at the first letter. And it is taller than any header's
* text, which is what lets the button fill a header row rather than sit in the middle of one.
*/
private val GLYPH_BUTTON_SIZE = 48.dp
/**
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
* a back arrow.
*
* Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text
* brings none, so the second ring has to be asked for -- without it the pressed-state circle
* arrives at the first letter of the title.
*/
val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
/**
* A glyph you can press: the icon equivalent of a `TextButton`.
*
* Its own composable so that every icon button in the app is one size and one colour without each
* caller saying so, and so the [label] none of them displays is still there for a screen reader --
* which is also the answer to "what was that button for" six months from now.
*
* [enabled] is passed through rather than left to callers hiding the button: a control that comes
* and goes makes its own absence the signal, and absence cannot say whether there was nothing to do
* or nobody checked.
*/
@Composable
fun GlyphButton(
glyph: String,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colour: Color = MaterialTheme.colorScheme.primary,
) {
MarkButton(label, onClick, modifier, enabled) {
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
}
}
/**
* The same square, around a mark that is not a glyph.
*
* A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the
* size, spacing and touch target every other icon button already is. The caller still owes it a
* [label]: nothing here draws a word.
*/
@Composable
fun MarkButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
mark: @Composable () -> Unit,
) {
IconButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
) {
mark()
}
}
/**
* The square a glyph button occupies, with a spinner in it instead of a mark.
*
* For a button whose work is under way. It takes the button's whole box rather than the mark's, so
* swapping one for the other leaves everything in the row exactly where it was.
*/
@Composable
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
) {
CircularProgressIndicator(Modifier.size(GLYPH_EXTENT), strokeWidth = 2.dp)
}
}
/**
* One icon, drawn as text.
*
* Callers that are already inside something pressable use this; [GlyphButton] is the one that adds
* the press. Either way the caller owes it a description, since neither draws a word.
*/
@Composable
fun Glyph(
glyph: String,
modifier: Modifier = Modifier,
colour: Color = MaterialTheme.colorScheme.primary,
size: TextUnit = GLYPH_SIZE,
) {
// Line height of the point size, which for this font is the square the glyph draws in: its
// ascent and descent add up to exactly one em. Left to the inherited body style the line box
// was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than
// horizontal.
Text(
glyph,
fontFamily = NerdIcons,
fontSize = size,
lineHeight = size,
color = colour,
modifier = modifier,
)
}
@@ -1,352 +0,0 @@
package com.example.aiapp
import android.Manifest
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import kotlin.concurrent.thread
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.json.JSONObject
/**
* Telling somebody a session wants them, when they are not looking at the app.
*
* This is a **foreground service**, which on Android is the only way to keep a connection open
* while the app is closed -- there has been no such thing as a long-lived background service since
* Android 8. It is what Syncthing does for the same reason. Discord is not a counter-example: it
* gets a push from Google's servers, which would mean this backend talking to Google about
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
*
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
* cannot be and because it should not be: it is the honest indicator that something is holding a
* connection open.
*/
class NotificationService : Service() {
@Volatile private var stream: HttpURLConnection? = null
@Volatile private var stopping = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val settings = loadServerSettings(this)
if (settings == null) {
// Nothing to connect to. Stopping rather than idling: a service holding no connection
// still costs the ongoing notification, which would be announcing work that is not
// happening.
stopSelf()
return START_NOT_STICKY
}
// Through ServiceCompat so the type is stated once and ignored on the versions that predate
// types, rather than branching here.
ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType())
thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) }
// Restarted if Android kills it, which is the whole point: the window this covers is
// exactly the one where nobody is watching.
return START_STICKY
}
override fun onDestroy() {
stopping = true
stream?.disconnect()
}
/**
* Follows the backend's notification stream, reconnecting until stopped.
*
* A dropped connection is the ordinary case here rather than an error, so it retries quietly
* and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell
* you whether anything happened" is noise about a condition nobody can act on, and the session
* list already says what is waiting when they next look.
*/
private fun follow(settings: ServerSettings) {
while (!stopping) {
try {
readStream(settings)
} catch (_: IOException) {
// Deliberate: see above.
}
if (stopping) return
try {
Thread.sleep(RECONNECT_DELAY_MS)
} catch (_: InterruptedException) {
return
}
}
}
private fun readStream(settings: ServerSettings) {
val connection =
URL("${settings.baseUrl}/notifications").openConnection() as HttpURLConnection
stream = connection
try {
connection.applyPinnedTls()
connection.connectTimeout = CONNECT_TIMEOUT_MS
// No read timeout, for the reason EventStream gives: between notifications there is
// nothing to read, possibly for hours.
connection.readTimeout = 0
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
connection.setRequestProperty("Accept", "text/event-stream")
if (connection.responseCode != 200) {
throw IOException("HTTP ${connection.responseCode} for the notification stream")
}
val reader = connection.inputStream.bufferedReader()
val data = StringBuilder()
while (!stopping) {
val line = reader.readLine() ?: break
when {
line.isEmpty() -> {
if (data.isNotEmpty()) show(parseNotification(data.toString()))
data.clear()
}
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
else -> {} // comments (keep-alives) and ids: nothing to do
}
}
} finally {
connection.disconnect()
stream = null
}
}
/**
* One notification per session, replacing that session's previous one.
*
* Keyed by session id rather than accumulating: two sessions wanting attention are two things
* to know about, but one session that finished and then asked a question is one thing -- the
* question. A stack of stale rows is how a drawer becomes something to clear rather than read.
*/
private fun show(notification: SessionNotification) {
// Nothing to tell somebody about the session they are reading. The transcript in front of
// them is already saying it.
if (isOnScreen(notification.sessionId)) return
// The app is up: it says this itself, as a banner over whatever screen they are on. Never
// both -- one thing happened, and a drawer filling up behind an app that already showed you
// each one is a drawer nobody reads.
if (handOver(notification)) return
val manager = NotificationManagerCompat.from(this)
// Two different noes, and both are answers rather than faults: the runtime permission
// refused, and notifications switched off for the app in Android's own settings.
//
// The permission only exists from Android 13. Asking an older version about it gets
// "denied" for a name it does not know, which read as the person having said no -- so every
// notification on Android 12 and below was silently dropped.
val allowed =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!allowed || !manager.areNotificationsEnabled()) {
return
}
val open =
PendingIntent.getActivity(
this,
0,
sessionIntent(this, notification.sessionId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val built =
NotificationCompat.Builder(this, ALERT_CHANNEL)
.setContentTitle(notification.title)
.setContentText(attentionLine(notification.kind))
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentIntent(open)
.setAutoCancel(true)
.setWhen((notification.at * 1000).toLong())
.setShowWhen(true)
.build()
manager.notify(notification.sessionId, ALERT_ID, built)
}
/**
* The type Android 14+ requires a foreground service to declare, and nothing before it.
*
* Named behind a version check rather than passed as a constant: the value is inlined at
* compile time and would be handed to platforms that have no concept of it, which is what
* lint's InlinedApi exists to catch.
*/
private fun foregroundType(): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
0
}
private fun ongoingNotification(): Notification =
NotificationCompat.Builder(this, ONGOING_CHANNEL)
.setContentTitle("Watching for sessions that need you")
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
companion object {
/**
* Starts the service if there is a server to connect to, and stops it otherwise.
*
* Called on every launch rather than once: a service Android killed does not restart itself
* if the process was replaced, and asking for one that is already running is free.
*/
fun sync(context: Context) {
val intent = Intent(context, NotificationService::class.java)
if (loadServerSettings(context) == null) {
context.stopService(intent)
return
}
createChannels(context)
ContextCompat.startForegroundService(context, intent)
}
/**
* Two channels, because they are two different things to be told.
*
* The alerts are what somebody turned this on for, so they get the default importance. The
* ongoing one is the platform's tax for staying connected, so it takes the lowest
* importance that exists. Both are created before the service starts, since posting to a
* channel that does not exist is silently dropped.
*/
private fun createChannels(context: Context) {
val manager = NotificationManagerCompat.from(context)
manager.createNotificationChannel(
NotificationChannelCompat.Builder(
ALERT_CHANNEL,
NotificationManagerCompat.IMPORTANCE_DEFAULT,
)
.setName("Sessions needing attention")
.build()
)
manager.createNotificationChannel(
NotificationChannelCompat.Builder(
ONGOING_CHANNEL,
NotificationManagerCompat.IMPORTANCE_MIN,
)
.setName("Staying connected")
.build()
)
}
/**
* The session somebody is looking at, or null when no screen is showing one.
*
* Process-wide state, which the rest of this app does without: Android constructs the
* service and the composition draws the screen, so the two have no common owner. Clearing
* names the session rather than setting null outright, because moving from one session to
* another composes the new screen before the old one's coroutine is cancelled -- an
* unconditional clear would throw away the new screen's claim.
*/
@Volatile private var onScreen: String? = null
private fun isOnScreen(sessionId: String) = onScreen == sessionId
/**
* The way a notification reaches the app instead of Android's drawer.
*
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
* nor blocks the thread reading the stream, and the buffer is there so a handful of
* sessions finishing together all land rather than the last one winning.
*/
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
/** Everything meant for the screen rather than the drawer; see [toApp]. */
val forTheScreen: SharedFlow<SessionNotification> = toApp.asSharedFlow()
private fun handOver(notification: SessionNotification) =
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
fun showing(context: Context, sessionId: String) {
onScreen = sessionId
// Whatever was posted about it before is about to be read, so it has nothing left to
// say.
NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID)
}
/** They have stopped, unless another screen has claimed it since. */
fun stoppedShowing(sessionId: String) {
if (onScreen == sessionId) onScreen = null
}
private const val ALERT_CHANNEL = "sessions"
private const val ONGOING_CHANNEL = "connection"
private const val ONGOING_ID = 1
/** Shared by every alert; the session id is the tag that separates them. */
private const val ALERT_ID = 2
private const val RECONNECT_DELAY_MS = 5_000L
}
}
/**
* The intent that opens one session, and the id it carries back out.
*
* The two halves are written together so neither can be changed without the other, and the scheme
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look
* at.
*
* The id rides in the intent's **data** rather than in an extra, which is not a style choice:
* PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring
* extras. Carried as an extra, every session's notification would update one shared PendingIntent
* and every tap would open whichever session was notified last.
*/
fun sessionIntent(context: Context, sessionId: String): Intent =
Intent(context, MainActivity::class.java)
.setAction(Intent.ACTION_VIEW)
.setData(
// Built rather than concatenated so an id needing escaping survives the round trip;
// lastPathSegment below decodes what appendPath encoded.
Uri.Builder().scheme("aiapp").authority("session").appendPath(sessionId).build()
)
/** The session [sessionIntent] named, or null for any other URI -- enrollment's included. */
fun notifiedSessionId(uri: Uri): String? =
if (uri.scheme == "aiapp" && uri.host == "session") uri.lastPathSegment else null
/** One frame of `GET /notifications`. */
data class SessionNotification(
val sessionId: String,
val title: String,
/** The wire's word: "awaitingInput" or "finished". */
val kind: String,
val at: Double,
)
/**
* What a notification asks of the reader, in the words they see.
*
* What they have to do, not what the session did: "awaitingInput" is the wire's word and says
* nothing to somebody reading a lock screen. One function because the same fact is shown in two
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift.
*/
fun attentionLine(kind: String): String =
when (kind) {
"awaitingInput" -> "Waiting for you"
else -> "Finished"
}
fun parseNotification(json: String): SessionNotification {
val body = JSONObject(json)
return SessionNotification(
sessionId = body.getString("sessionId"),
title = body.getString("title"),
kind = body.getString("kind"),
at = body.optDouble("at", 0.0),
)
}
@@ -1,141 +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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* A message another agent sent this session, closed until somebody asks.
*
* Closed by default, like a tool call and for the same reason: these are long, there can be several
* in a row, and what a reader scanning the transcript needs from one is that it happened and who
* sent it. The first line comes with the heading because a name alone does not say which message
* this was.
*
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
* transcript that puts it in their voice is making a claim about who asked for the work that
* follows.
*
* Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block,
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought;
* what matters here is that the pieces have to add up to the card that was there before, so the
* fill, the corner radius and the padding all live in [peerSurface].
*/
@Composable
fun PeerHeadRow(
item: TranscriptItem.PeerNote,
open: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier.cardPiece(
top = true,
bottom = !open,
fill = CardDefaults.cardColors().containerColor,
onPress = onToggle,
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
if (!open) {
Spacer(Modifier.width(8.dp))
Text(
item.text.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
// The head, not the tail: a message is identified by how it opens.
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/**
* One block of an opened peer message, on the same card the heading started.
*
* Clickable like the heading, so the card still shuts wherever it is pressed -- it was one control
* before it was several items, and which piece the finger lands on is not something the reader
* chose.
*/
@Composable
fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggle: () -> Unit) {
Column(
Modifier.cardPiece(
top = false,
bottom = unit.last,
fill = CardDefaults.cardColors().containerColor,
onPress = onToggle,
)
) {
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
// Without this the card closes everywhere except on the text, which is most of it.
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
// The gap the card's own column used to provide between its heading and its prose, and
// between one block and the next -- inside the piece, so the card's fill runs through
// it.
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
}
}
}
/**
* One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it.
*
* A filled Material card is elevation zero, so there is no shadow that a seam would show through --
* which is the whole reason a card can be cut up at all. Each piece paints the caller's container
* colour and rounds only the corners at the ends of the message, so the pieces abut into one
* continuous card. Shared by the two rows cut this way -- an opened peer message and a long user
* message -- because two copies of the corner logic is how one of them grows a seam.
*
* The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top
* of it, the bottom piece the bottom, and the middle pieces neither.
*/
@Composable
fun Modifier.cardPiece(
top: Boolean,
bottom: Boolean,
fill: Color,
onPress: (() -> Unit)? = null,
): Modifier {
val square = CornerSize(0.dp)
val shape =
MaterialTheme.shapes.medium.copy(
topStart = if (top) MaterialTheme.shapes.medium.topStart else square,
topEnd = if (top) MaterialTheme.shapes.medium.topEnd else square,
bottomStart = if (bottom) MaterialTheme.shapes.medium.bottomStart else square,
bottomEnd = if (bottom) MaterialTheme.shapes.medium.bottomEnd else square,
)
return fillMaxWidth()
.clip(shape)
.background(fill)
.then(if (onPress == null) Modifier else Modifier.clickable(onClick = onPress))
.padding(
start = CARD_PADDING,
end = CARD_PADDING,
top = if (top) CARD_PADDING else 0.dp,
bottom = if (bottom) CARD_PADDING else 0.dp,
)
}
/** The room inside a sliced card, which was `Card { Column(padding(12.dp)) }`. */
private val CARD_PADDING = 12.dp
@@ -1,162 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* What is about to be sent, directly above the box it will be sent from.
*
* The count on the "+" button was the whole of what said an image was attached, so the only way to
* find out *which* image was to send it. A control belongs with the thing it acts on.
*
* Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is
* in it, so four attachments look like four of the same thing rather than four smaller ones.
*/
@Composable
fun PendingAttachments(
settings: ServerSettings,
sessionId: String,
refs: List<String>,
onRemove: (String) -> Unit,
modifier: Modifier = Modifier,
) {
if (refs.isEmpty()) return
Row(
modifier = modifier.horizontalScroll(rememberScrollState()).padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
refs.forEach { ref ->
if (isImageRef(ref)) PendingThumbnail(settings, sessionId, ref) { onRemove(ref) }
else PendingFile(ref) { onRemove(ref) }
}
}
}
/**
* One attachment, square, tap to take it back off.
*
* Removal is here because there is nowhere else it could be: an image picked by mistake could
* otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip.
*/
@Composable
private fun PendingThumbnail(
settings: ServerSettings,
sessionId: String,
ref: String,
onRemove: () -> Unit,
) {
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
val shape = RoundedCornerShape(8.dp)
Box(
Modifier.size(THUMBNAIL)
.clip(shape)
// An outline as well as a fill. Most of what gets attached here is a screenshot of a
// dark app, and cropped to a square its middle is often near-black -- against this
// background the tile then had no edge at all.
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
// Behind the picture as well as under a missing one, so the tile is a tile before
// anything has arrived to fill it.
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRemove)
.semantics { contentDescription = "Attached image, tap to remove" },
contentAlignment = Alignment.Center,
) {
when (val image = bitmap) {
// The two are told apart for the same reason the transcript's images are: one of them
// is worth waiting for and the other never resolves.
null ->
if (failed) {
Text(
"!",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// A spinner, as the transcript's images have: one appearance for "a picture is
// on its way", learned once. An ellipsis had to be read as a spinner not
// moving.
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
else ->
Image(
bitmap = image,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(THUMBNAIL),
)
}
// The whole square removes it, and this only says so. A cross small enough to sit in the
// corner of a 64dp thumbnail is smaller than a fingertip.
//
// The disc is sized here and the mark centred inside it, rather than the glyph being
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to
// the corner hung the visible mark over the edge.
Box(
Modifier.align(Alignment.TopEnd)
.padding(2.dp)
.size(20.dp)
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f), CircleShape),
contentAlignment = Alignment.Center,
) {
Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp)
}
}
}
/**
* One attached file: its name, tap to take it back off. The same height and removal as a thumbnail,
* so a row of mixed attachments is one row; the cross sits after the name because a tile this wide
* has no corner the eye goes to.
*/
@Composable
private fun PendingFile(ref: String, onRemove: () -> Unit) {
val name = attachmentName(ref)
val shape = RoundedCornerShape(8.dp)
Row(
Modifier.height(THUMBNAIL)
.clip(shape)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRemove)
.semantics { contentDescription = "Attached file $name, tap to remove" }
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FileName(name, Modifier.widthIn(max = FILE_TILE_WIDTH))
Spacer(Modifier.width(6.dp))
Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp)
}
}
private val THUMBNAIL = 64.dp
/** Wide enough for most names whole; longer ones lose their middle, keeping both ends. */
private val FILE_TILE_WIDTH = 200.dp
@@ -1,16 +0,0 @@
package com.example.aiapp
import com.example.wgapplink.PinnedTls
import java.net.HttpURLConnection
// PINNED_CA_PEM is generated at build time from the CA on the machine doing the build -- see the
// generatePinnedCert task in build.gradle.kts. It is deliberately not a checked-in constant: the
// private key that signs against it must never be anywhere this repo is, and an APK should pin
// whatever CA the backend it was built for actually serves.
//
// The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What
// stays here is which certificate this app pins.
private val pinned = PinnedTls(PINNED_CA_PEM)
/** Every request this app makes goes through this -- there is no unpinned path. */
fun HttpURLConnection.applyPinnedTls() = pinned.applyTo(this)
@@ -1,35 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
/**
* Verbatim text, on the surface that says so: a command about to be run, what a tool printed.
*
* A composable rather than a modifier repeated at each site, because the inset is part of it --
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
* copies of "clip, fill, pad" drift apart the first time one is adjusted.
*
* The colour is [rawSurface], which is also what a code block inside a reply is given.
*/
@Composable
fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier
.fillMaxWidth()
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded
// rectangle drawn at the same radius as the one behind it reads as a misprint.
.clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface)
.padding(horizontal = 8.dp, vertical = 6.dp),
content = content,
)
}
@@ -1,64 +0,0 @@
package com.example.aiapp
import java.time.Duration
import java.time.OffsetDateTime
// How long is left in a usage window. Shared by the session bar and the usage screen: the
// arithmetic is the same in both, so everything here returns the span or the state on its own and
// leaves the wording to the caller.
/**
* "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words.
*
* Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s
* left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the
* figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot
* round a shared measurement two different ways.
*/
fun formatSpan(until: Duration): String {
val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1)
return when {
up.toHours() >= 24 -> "${up.toDays()}d ${up.toHours() % 24}h"
up.toHours() > 0 -> "${up.toHours()}h ${up.toMinutes() % 60}m"
else -> "${up.toMinutes()}m"
}
}
/**
* What is known about when a usage window ends.
*
* Three answers rather than a nullable duration, because two of them shared `null` and they are not
* the same thing at all. A window the server sent no reset time for is one that is **not running**:
* the five-hour window is anchored to the block it started in, so between sessions there is nothing
* counting down and the API says so by omitting the field. A timestamp that did arrive and could
* not be read is the genuinely unknown case.
*
* Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on
* the one row somebody reads before starting something big -- and the usage dialog, looking at the
* same field, quietly drew nothing.
*/
sealed class WindowEnd {
/** No reset time was sent, so nothing is running in this window. Not a failure to find out. */
data object NotRunning : WindowEnd()
/** A timestamp arrived and could not be read. The one case that is actually unknown. */
data object Unreadable : WindowEnd()
/** How long is left. Negative once the window is past, which each caller words for itself. */
data class Ends(val until: Duration) : WindowEnd()
}
/**
* [resetsAt] as the server sent it -- absent, unreadable, or a moment -- against [now].
*
* [now] is a parameter rather than read here so a caller can drive it from state and have the
* countdown recompute on its own schedule.
*/
fun windowEnd(resetsAt: String?, now: OffsetDateTime): WindowEnd {
if (resetsAt == null) return WindowEnd.NotRunning
return try {
WindowEnd.Ends(Duration.between(now, OffsetDateTime.parse(resetsAt)))
} catch (_: Exception) {
WindowEnd.Unreadable
}
}
@@ -1,53 +0,0 @@
package com.example.aiapp
import android.content.Context
import androidx.core.content.edit
private const val ANCHORS = "session-scroll"
/**
* Where a session's transcript was left, so reopening it lands where reading stopped.
*
* Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by
* the row key the list draws with. An index means nothing across a reopen, since the transcript is
* fetched newest-first. The row key looks stable and is not: a tool row is named after its run,
* `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest
* page started with -- so an active session renames its tool runs every time it is reopened. A seq
* is the server's own numbering, assigned once and never moved.
*
* [unit] is which unit of the row the viewport started at and [offset] how far that unit was
* scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can
* be forty blocks long.
*/
data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0)
/**
* On this device rather than on the backend, which is where this app otherwise keeps state so every
* device sees it. Scroll position is the same exception a draft is: it is where the phone in
* somebody's hand is pointed.
*/
fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val stored =
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null)
?: return null
val fields = stored.split(':')
val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null
val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null
// Positions saved before the unit was recorded name the row's oldest unit, which is the closest
// older place -- the same choice [unitIndexFor] makes when a unit is gone.
return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0)
}
/**
* Records where [sessionId] is being read, or forgets it when [anchor] is null.
*
* The path out is reading to the newest end, which is what the caller passes null for: a session
* left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its
* key behind, for the reason and at the cost `Drafts.kt` describes.
*/
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
if (anchor == null) remove(sessionId)
else putString(sessionId, "${anchor.seq}:${anchor.offset}:${anchor.unit}")
}
}
@@ -1,27 +0,0 @@
package com.example.aiapp
import android.content.Context
import android.net.Uri
import com.example.wgapplink.ServerStore
/**
* Where the backend is and how to authenticate to it. Absent until the phone is enrolled -- by
* scanning the server's terminal QR (an `aiapp://enroll` URI the camera app hands to MainActivity)
* or by typing the fields into the settings screen.
*/
typealias ServerSettings = com.example.wgapplink.ServerSettings
/**
* This app's enrollment, which is the whole of what is product-specific about it.
*
* Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev
* Updater, and the key alias names the Android Keystore key the token is already sealed under on
* every enrolled phone -- changing it would leave those phones reading as not enrolled.
*/
private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key")
fun loadServerSettings(context: Context): ServerSettings? = store.load(context)
fun saveServerSettings(context: Context, settings: ServerSettings) = store.save(context, settings)
fun parseEnrollmentUri(uri: Uri): ServerSettings? = store.parseEnrollmentUri(uri)
@@ -1,176 +0,0 @@
package com.example.aiapp
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
/**
* A session wanting attention, said over the app rather than through Android's drawer.
*
* Two places can carry the same fact and only one is right at a time. A row in the shade is for
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
* while these are on screen the stream is delivered here instead, which is arranged by the
* collection below and nothing else.
*
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
*/
@Composable
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
val queue = remember { mutableStateListOf<SessionAlert>() }
// What tells two notifications about one session apart, and what a replaced banner gets a new
// one of so its timer starts again rather than inheriting the remains of the last one's.
var arrivals by remember { mutableIntStateOf(0) }
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
try {
NotificationService.forTheScreen.collect { notification ->
arrivals++
val alert = SessionAlert(notification, arrivals)
// One banner per session, replacing that session's own -- the same rule the
// drawer follows: a session that finished and then asked a question is one
// thing to know about, the question. It keeps its place in the queue rather
// than moving to the end, because the reader may already be reaching for it.
val already = queue.indexOfFirst {
it.notification.sessionId == notification.sessionId
}
if (already >= 0) queue[already] = alert else queue.add(alert)
}
} finally {
// Leaving the app hands the job back to the drawer, so nothing arriving while it is
// away is lost. What would be lost is the truth of what is already up: these say a
// session wants somebody *now*, and one still sitting here on a return several
// minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock
// with the window.
queue.clear()
}
}
}
// Oldest at the top, so a new one appears below the ones already being read instead of shoving
// them down the screen mid-reach.
Column(modifier.fillMaxWidth().padding(8.dp)) {
queue.forEach { alert ->
key(alert.arrival) {
AlertBanner(
alert = alert,
onOpen = {
queue.remove(alert)
onOpen(SessionOpenRequest(alert.notification.sessionId, alert.arrival))
},
onGone = { queue.remove(alert) },
)
}
}
}
}
/** One notification queued for the screen, with the arrival that tells it from its predecessor. */
private data class SessionAlert(val notification: SessionNotification, val arrival: Int)
/**
* One banner: what wants attention, and how long this has left to say so.
*
* The bar and the going away are one value rather than a bar beside a timer, because two of them
* would be two accounts of the same countdown and only one can be the one that fires.
*/
@Composable
private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) {
val swipe = rememberSwipeToDismissBoxState()
val life = remember { Animatable(1f) }
LaunchedEffect(Unit) {
life.animateTo(0f, animationSpec = tween(ALERT_LIFE_MS, easing = LinearEasing))
onGone()
}
// Settled is "still where it started"; anything else is a push that carried far enough for the
// gesture to commit, which the platform decides rather than this screen.
LaunchedEffect(swipe.currentValue) {
if (swipe.currentValue != SwipeToDismissBoxValue.Settled) onGone()
}
SwipeToDismissBox(
state = swipe,
// Nothing behind it. Pushing one of these away means the same thing whichever way it went,
// so a coloured ground with an icon would be drawing a distinction that isn't there.
backgroundContent = {},
modifier = Modifier.padding(bottom = 8.dp),
) {
Card(
onClick = onOpen,
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
),
// Outlined, because the step it needs to make is not one this palette can make with a
// tint: the card under a banner on the session list is the same surface, so a banner
// relying on colour alone reads as one more row in the way. The border is the one cue.
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
) {
Column(Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 10.dp)) {
Text(
alert.notification.title,
style = MaterialTheme.typography.titleSmall,
// One line, cut at the tail: a session is identified by the start of its name,
// and a banner that grew with the name would move the one below it.
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
attentionLine(alert.notification.kind),
style = MaterialTheme.typography.labelLarge,
// The list's own colour for a session waiting on a person, so the banner and
// the row behind it are saying one thing rather than two.
color =
if (alert.notification.kind == "awaitingInput") awaitingColor
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LinearProgressIndicator(
progress = { life.value },
// Blue because it is reporting how much of something is left rather than passing
// judgement on it. Stated beside the track, which is the card's own colour so that
// the spent part reads as empty rather than as a second bar.
color = progressColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
drawStopIndicator = {},
gapSize = 0.dp,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
/**
* How long a banner stays if nobody touches it.
*
* Long enough to read a session name and a line, short enough that a stack of them clears itself
* while somebody is still on the screen that produced them. The bar makes the number visible.
*/
private const val ALERT_LIFE_MS = 6_000
@@ -1,265 +0,0 @@
package com.example.aiapp
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* One image from the session's files route: the bitmap once it arrives, and whether it never will.
*
* [failed] exists because the two empty states differ in kind -- still coming and never coming --
* and a reader can act on the second; each caller supplies its own words for them.
*/
data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean)
/**
* Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling
* does not refetch. Shared by the transcript's images and the composer's pending attachments,
* because the fetch, the decode and the two-state answer are one block of logic that had been
* written twice.
*/
@Composable
fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap {
var state by remember(ref) { mutableStateOf(SessionBitmap(null, failed = false)) }
LaunchedEffect(ref) {
state =
try {
val bytes =
withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
SessionBitmap(decoded, failed = decoded == null)
} catch (_: ApiException) {
SessionBitmap(null, failed = true)
}
}
return state
}
/**
* An image in the transcript: a fixed-height thumbnail that opens full screen.
*
* The height is decided before the bytes arrive and never changes. An image row that grew when it
* finished loading pushed everything below it, so a transcript being read scrolled itself -- and in
* a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes.
*
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
* than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the
* screen, which draws [SessionImageViewer] outside the list.
*/
@Composable
fun SessionImage(
settings: ServerSettings,
sessionId: String,
ref: String,
onOpen: (String) -> Unit,
) {
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
val height = thumbnailHeight()
val heightPx = with(LocalDensity.current) { height.roundToPx() }
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
when (val image = bitmap) {
// Two states, not one: an image still arriving and an image that will never arrive look
// nothing alike to a reader who can do something about the second.
null ->
if (failed) {
Text(
"[image $ref unavailable]",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LoadingImage(height)
}
else ->
Image(
bitmap = image,
contentDescription = "Attached image, tap to view full screen",
contentScale = ContentScale.Fit,
filterQuality = enlargingFilter(image.height, heightPx),
modifier = Modifier.fillMaxSize().clickable { onOpen(ref) },
alignment = Alignment.CenterStart,
)
}
}
}
/**
* The image somebody opened, drawn by the screen rather than by the row it was tapped in.
*
* The row is the wrong place to hold this, and it took a real fault to see why: an image from a
* `Read` on its own is a row of one call, and the moment the next call arrives the two become a
* group -- a different composable in a different part of the tree, so everything the old subtree
* remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the
* transcript because the session made another tool call.
*
* Held by the screen, none of that reaches it: what is open is a property of the screen.
*
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
* through. Paid deliberately: it is one request for a picture somebody asked to see.
*/
@Composable
fun SessionImageViewer(
settings: ServerSettings,
sessionId: String,
ref: String,
onClose: () -> Unit,
) {
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose),
contentAlignment = Alignment.Center,
) {
when (val image = bitmap) {
// Two states, not one, exactly as the thumbnail has them. Stated in white because
// this box paints its own black behind them and a theme colour would be picked
// against a surface that is not there.
null ->
if (failed) {
Text(
"Image $ref is unavailable",
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
)
} else {
// The whole dialog is the area this picture is about to fill, so the
// spinner sits in the middle of it. White for the same reason the words
// beside it are.
CircularProgressIndicator(color = Color.White)
}
else -> ZoomableImage(image)
}
}
}
}
/**
* The room a picture is about to take, with a spinner in the middle of it.
*
* A square of the row's own height rather than the full width of the transcript: the height is what
* [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width
* placeholder would promise a picture wider than most turn out to be.
*
* Tinted, so the reader can see that something is being kept for a picture -- which is also what
* distinguishes it from the failure beside it, words on the ordinary surface.
*/
@Composable
private fun LoadingImage(height: Dp) {
Box(
Modifier.size(height)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(Modifier.size(LOADING_SPINNER), strokeWidth = 2.dp)
}
}
/** Small enough to sit inside the thumbnail's square without filling it. */
private val LOADING_SPINNER = 24.dp
/**
* Four lines of the body style the transcript is set in.
*
* Measured from the type rather than written as a dp, so it stays four lines when the text size
* changes -- including when the reader has scaled fonts up, which is when a hardcoded height is
* wrong.
*/
@Composable
private fun thumbnailHeight(): Dp {
val line = MaterialTheme.typography.bodyLarge.lineHeight
val density = LocalDensity.current
return remember(line, density) {
with(density) { if (line.isSpecified) (line * 4).toDp() else 96.dp }
}
}
/**
* Nearest neighbour when the image is being enlarged, smooth when it is being shrunk.
*
* A small image blown up with interpolation turns into a blur that hides what it is -- the same
* image with hard pixel edges stays readable. Shrinking wants the opposite.
*/
private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality =
if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High
/**
* The image on its own, as large as it fits, with pinch to zoom.
*
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
* visible.
*/
@Composable
private fun ZoomableImage(image: ImageBitmap) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Image(
bitmap = image,
contentDescription = "Attached image",
contentScale = ContentScale.Fit,
// Zoomed in, the reader is looking at pixels on purpose.
filterQuality = FilterQuality.None,
modifier =
Modifier.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
// Floor of 1 so the image cannot be pinched smaller than fitted, which is
// already the whole of it; a ceiling so it cannot be lost off-screen.
scale = (scale * zoom).coerceIn(1f, 8f)
if (scale > 1f) {
offsetX += pan.x
offsetY += pan.y
} else {
offsetX = 0f
offsetY = 0f
}
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offsetX
translationY = offsetY
},
)
}
@@ -1,530 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
*
* No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one.
* What stays here is the button that adds a session, because that acts on this list and nothing
* else.
*/
@Composable
fun SessionListScreen(
settings: ServerSettings,
reloadToken: Int,
onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit,
) {
val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Which session cards are expanded to show their subagents, and what each expansion fetched.
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
// from
// whatever the server last said, and this belongs to the reader's own choice, which survives a
// refresh.
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
var subagentLoads by remember {
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
}
fun loadSubagents(sessionId: String) {
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
scope.launch {
subagentLoads =
subagentLoads +
(sessionId to
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
)
} catch (e: ApiException) {
LoadState.failed(e)
})
}
}
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
// own card. The two scopes are decided by whether the server answered: it answered and refused,
// so this says nothing about the other rows.
//
// Cleared on the next successful load below -- an entry outlives its session otherwise.
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
// the rows are rebuilt from whatever the server last said and this belongs to the request.
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
// This phone's copies of these sessions' transcripts, pruned from here because this is where a
// session stops existing. See TranscriptCache.
val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
fun refresh() {
listState = LoadState.Loading
scope.launch {
listState =
try {
val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap()
// The path out for a cached transcript whose session was deleted somewhere
// else. This list is the only place that ever learns the full set. On the
// answer rather than in `finally`: a list that failed to arrive says nothing
// about which sessions exist.
withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
}
// A session gone from this answer cannot still be expanded, and an expanded one
// that is still here asks again -- its subagents may have changed since the
// last
// fetch.
val ids = loaded.value.map { it.id }.toSet()
expandedSessions = expandedSessions intersect ids
subagentLoads = subagentLoads.filterKeys { it in ids }
expandedSessions.forEach(::loadSubagents)
loaded
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
LaunchedEffect(reloadToken) { refresh() }
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
when (val state = listState) {
is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is already a whole
// sentence naming the address and what to check, so a prefix here read "Couldn't
// reach the server: Couldn't reach the server at ...".
is LoadState.Error ->
Text(
state.message,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded -> {
if (state.value.isEmpty()) {
Text(
"No sessions. Tap + to spawn one.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Awaiting-answer first (the point of the screen), then most recently active.
val ordered =
state.value.sortedWith(
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
.thenByDescending { it.lastActivity }
)
LazyColumn {
uniqueItems(ordered, key = { it.id }) { session ->
SessionCard(
session = session,
error = deleteErrors[session.id],
deleting = session.id in deleting,
onOpen = { onOpen(session) },
onLongPress = { confirmingDelete = session },
expanded = session.id in expandedSessions,
subagents = subagentLoads[session.id],
onToggleSubagents = {
if (session.id in expandedSessions) {
expandedSessions = expandedSessions - session.id
} else {
expandedSessions = expandedSessions + session.id
loadSubagents(session.id)
}
},
onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) },
)
Spacer(Modifier.height(12.dp))
}
}
}
}
}
FloatingActionButton(
onClick = onSpawn,
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
) {
Text("+", style = MaterialTheme.typography.headlineMedium)
}
}
confirmingDelete?.let { session ->
// Reset per session, so a toggle turned on for one conversation is not still on for the
// next. Off to begin with: see [deleteSession].
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Delete \"${session.title}\"?") },
text = {
// Two different acts behind one button, so it says which one this is. What
// separates them is whether the *driver* keeps its own record of the conversation
// -- the Claude Code CLI does, whether this app spawned the session or imported it;
// echo and llama.cpp do not.
//
// This used to branch on `imported`, above a comment asserting that "a session
// started here has no copy anywhere". That was false for every claude-cli session
// this app spawned, and getting it wrong in that direction is the expensive one:
// "this can't be undone", said of something that can, spends the credibility the
// sentence needs.
//
// Neither branch promises a restore. The recoverable one says what is known -- the
// driver keeps its own record -- rather than that the file is still there, and it
// names what goes either way, because this app's transcript holds images, peer
// messages and commands the CLI's own record never had.
Column {
Text(
when {
!session.keepsOwnTranscript ->
"Kills the process and deletes the conversation. Nothing else " +
"keeps a copy, so this can't be undone."
// The sentence below is the one the toggle makes false, which is why it
// is written twice rather than appended to: leaving "should still be
// there to import again" on screen beside a switch that removes it is
// the reassurance being read at the moment it stops being true.
alsoDeleteForeign ->
"Kills the process and deletes both copies of the conversation: " +
"this app's, and Claude Code's own transcript on the " +
"machine. Nothing keeps another, so this can't be undone."
else ->
"Stops the process and deletes this app's copy of the " +
"conversation, including any images, peer messages and " +
"commands recorded only here. Claude Code keeps its own " +
"transcript on the machine, so the conversation itself " +
"should still be there to import again."
}
)
// Only where there is a second copy to decide about. Absent rather than
// disabled, because this is not a capability being withheld: for echo and
// llama.cpp there is no other transcript, and a switch offering to delete one
// would be asking about something that does not exist.
if (session.keepsOwnTranscript) {
Spacer(Modifier.height(16.dp))
// Its own row rather than beside the paragraph: a switch is taller than a
// line of text and re-centres whatever shares a row with it.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Delete Claude Code's transcript too",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(12.dp))
Switch(
checked = alsoDeleteForeign,
onCheckedChange = { alsoDeleteForeign = it },
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
confirmingDelete = null
// Marked here rather than after the request returns: the row has to say
// something is happening to it from the moment it is asked for.
deleting = deleting + session.id
deleteErrors = deleteErrors - session.id
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it.
transcriptCache.session(TranscriptAddress(session.id)).purge()
}
// Only this row, and only what changed. Refetching the list instead
// put every other session back through loading and handed the
// reader an empty screen, to report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState =
LoadState.Loaded(
loaded.value.filterNot { it.id == session.id }
)
}
} catch (e: ApiException) {
// Kept, because it is still there: the server refused, so the
// session it refused about is exactly as it was.
deleteErrors =
deleteErrors + (session.id to (e.message ?: "Delete failed"))
} finally {
deleting = deleting - session.id
}
}
}
) {
// Coloured by consequence: this takes something away, and does so wherever it
// appears -- the same rule the import screen's Delete follows.
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SessionCard(
session: SessionSummary,
/** What went wrong acting on *this* session, if anything has. */
error: String?,
/**
* Whether this session is being deleted right now.
*
* Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its
* way out without claiming it has gone: a row removed the moment Delete is pressed is a promise
* about a request that has not been answered yet.
*/
deleting: Boolean,
onOpen: () -> Unit,
onLongPress: () -> Unit,
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
expanded: Boolean,
/** What the expander's own fetch answered, or null before it has been asked. */
subagents: LoadState<List<SubagentSummary>>?,
onToggleSubagents: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
) {
BusyItem(label = if (deleting) "deleting" else null) {
Card(
// Off while the delete is in flight: a card that still opens a session it is deleting
// is a race the reader can start by tapping. On the card rather than in [BusyItem],
// which leaves gestures alone so the list still scrolls.
Modifier.fillMaxWidth()
.combinedClickable(
enabled = !deleting,
onClick = onOpen,
onLongClick = onLongPress,
)
) {
Column(Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
session.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
StatusText(session.status)
}
Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
// Machine, then what runs on it, then what it is set to: the same order and
// separator as the session screen's header and the usage dialog, so one
// pair of facts is not written three ways.
listOfNotNull(
session.setupName,
session.provider,
session.model?.let { modelLabel(it) },
)
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(session.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
error?.let {
Spacer(Modifier.height(8.dp))
// The server's own words, unprefixed, the way every other failure is shown.
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
// Nothing at all for a card with no subagents: a disabled expander here would be
// noise on every ordinary session's card. Its own row at the bottom rather than
// beside the title or the machine line, so opening it never displaces text that was
// already on screen -- see UI_RULES on a control not displacing the text beside it.
if (session.subagents > 0) {
Spacer(Modifier.height(8.dp))
// The platform's minimum touch height, not the chevron's own ten or so dp:
// at the chevron's height a tap meant for it landed on the first subcard
// beneath and opened a subagent instead.
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(enabled = !deleting, onClick = onToggleSubagents)
.semantics {
contentDescription =
if (expanded) "Collapse subagents" else "Expand subagents"
},
) {
Chevron(if (expanded) Pointing.Up else Pointing.Down)
}
if (expanded) {
Spacer(Modifier.height(4.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (subagents) {
null,
is LoadState.Loading ->
CircularProgressIndicator(
modifier = Modifier.width(20.dp).height(20.dp),
strokeWidth = 2.dp,
)
is LoadState.Error ->
// Said here rather than left silent: a fetch that failed and an
// expander that simply found nothing must not look the same --
// see UI_RULES on designing the unknown state first.
Text(
subagents.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded ->
subagents.value.forEach { subagent ->
SubagentCard(
subagent,
onClick = { onOpenSubagent(subagent) },
)
}
}
}
}
}
}
}
}
}
/**
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
* filled one, so the nesting reads as one step rather than as another session.
*/
@Composable
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
* rather than a branch inside [StatusText], because a subagent's three states are not that
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
* and never something of its own to have merely stopped.
*/
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@Composable
fun StatusText(status: String) {
val (label, color) =
when (status) {
"awaitingInput" -> "your turn" to awaitingColor
"running" -> "running" to runningColor
"compacting" -> "compacting" to commandColor
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
// Said in words, because it differs in kind from the others rather than in degree: the
// session is not idle and has not exited, nobody has been able to find out which. A
// muted colour alone would read as one of the quiet states.
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (sessionWorking(status)) {
// The same colour as the word beside it: the two are one signal, and a spinner in the
// theme's accent says the state is something other than what the label says.
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp,
color = color,
)
Spacer(Modifier.width(6.dp))
}
Text(label, style = MaterialTheme.typography.labelLarge, color = color)
}
}
fun relativeTime(epochSeconds: Double): String {
val seconds = (System.currentTimeMillis() / 1000.0 - epochSeconds).toLong()
return when {
seconds < 60 -> "just now"
seconds < 3600 -> "${seconds / 60}m ago"
seconds < 86400 -> "${seconds / 3600}h ago"
else -> "${seconds / 86400}d ago"
}
}
File diff suppressed because it is too large. Load diff
@@ -1,562 +0,0 @@
package com.example.aiapp
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* What can be changed about one session, as opposed to about this app.
*
* Over the session rather than a step down from it: everything here is about the conversation
* behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
* controls and hid the thing they act on.
*
* The model and the permission mode are deliberately still on the session's own bar, because those
* are changed *while* reading a turn -- "not this model, try that one".
*
* Captions are for what a control costs rather than for what it is. A paragraph under every control
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
* and Reload do, because what those two take away is not visible from here.
*/
@Composable
fun SessionSettingsDialog(
settings: ServerSettings,
sessionId: String,
/**
* What the session is called now, as the screen behind this knows it -- see the rename below.
*/
title: String,
onRenamed: (String) -> Unit,
/**
* How hard the model thinks, as the session reports it, or null for the CLI's own default.
*
* Taken from the row this dialog was opened over rather than fetched, because unlike the
* notification switch there is nothing else that changes it: the level is this app's to set and
* the server does not resolve it into something else.
*/
effort: String?,
/** Whether a level does anything here; the row is left out entirely where it does not. */
takesEffort: Boolean,
/**
* What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it.
*/
cachedBytes: Long?,
onReload: () -> Unit,
onDismiss: () -> Unit,
/**
* Copies what this session costs to draw. Built by the session screen, because everything it
* measures is that screen's own state.
*/
onCopyRenderReport: () -> Unit,
/**
* Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on
* every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than
* always-present-but-disabled: this has no meaning at all outside the bench build, and a
* control with nothing behind it on every other build is not a state worth drawing.
*/
onRunBenchmark: (() -> Unit)? = null,
) {
val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) }
var level by remember(sessionId) { mutableStateOf(effort) }
var effortError by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
// whenever the list was last fetched, so drawing the switch straight from it would show a
// position that may have been changed since. Until the answer arrives the switch is disabled
// and a spinner sits beside it, which is what not knowing looks like.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) }
// The same three-state shape the notification switch has, for the same reason: until the
// server has answered, the switch is disabled rather than showing a position nothing confirmed.
var autoResume by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) }
// When the server next intends to ask whether the limit has lifted, or null when nothing is
// waiting. Read once with everything else: it moves on the server's schedule, not this
// screen's, and a figure that redrew itself here would be this app re-measuring what it was
// told.
var resumeAt by remember(sessionId) { mutableStateOf<Double?>(null) }
var resumeError by remember { mutableStateOf<String?>(null) }
// Where the session works. Null until the server has been asked, for the same reason the switch
// above is. An empty answer is a session that was never given a directory, which is not the
// same as one whose directory is unknown -- the field is only enabled once one of those is
// settled.
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
var typedCwd by remember(sessionId) { mutableStateOf("") }
var cwdError by remember { mutableStateOf<String?>(null) }
var movingCwd by remember { mutableStateOf(false) }
LaunchedEffect(sessionId) {
try {
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
notify = fresh.notify
autoResume = fresh.autoResume
resumeMessage = fresh.autoResumeMessage
resumeAt = fresh.resumeAt
cwd = fresh.cwd.orEmpty()
typedCwd = fresh.cwd.orEmpty()
} catch (e: ApiException) {
// Left unknown rather than falling back to the stale row: the switch stays disabled,
// instead of offering a position nothing confirmed.
notifyError = e.message
notify = null
resumeError = e.message
autoResume = null
}
}
/**
* Moves the session, which ends the process that is in the old directory.
*
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
* process, and a stopped session is a state this app already has a word and a button for.
*/
fun moveCwd() {
val chosen = typedCwd.trim()
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
movingCwd = true
cwdError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
cwd = chosen
} catch (e: ApiException) {
// Where it happened: this field is the only thing on screen that knows a move was
// asked for, and the reason is usually the path itself.
cwdError = e.message
} finally {
movingCwd = false
}
}
}
/**
* Chooses a thinking level, which ends the process the old level was launched with.
*
* Put back if the request is refused, for the reason the notification switch below gives: a
* control that stays where it was put after a refusal is stating something untrue.
*/
fun setEffort(chosen: String?) {
val was = level
level = chosen
effortError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
} catch (e: ApiException) {
level = was
effortError = e.message
}
}
}
// Moved optimistically so the switch answers the finger that moved it, and put back if the
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
// and one that stays moved after a refusal lies.
fun setNotify(wanted: Boolean) {
val was = notify
notify = wanted
notifyError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
} catch (e: ApiException) {
notify = was
notifyError = e.message
}
}
}
/**
* Turns auto-resume on or off, or changes what it would say.
*
* One request for both, because the server takes one: switching it on and typing the message
* are two halves of the same decision, and sending them separately would leave a moment where
* the session is armed with the old words.
*
* Put back if refused, like the notification switch. Turning it off also clears what was
* scheduled -- said here rather than only on the server, or the row would go on naming a time
* that no longer exists.
*/
fun setAutoResume(on: Boolean, message: String) {
val wasOn = autoResume
val wasMessage = resumeMessage
val wasAt = resumeAt
autoResume = on
resumeMessage = message
if (!on) resumeAt = null
resumeError = null
scope.launch {
try {
withContext(Dispatchers.IO) {
setSessionAutoResume(settings, sessionId, on, message)
}
} catch (e: ApiException) {
autoResume = wasOn
resumeMessage = wasMessage
resumeAt = wasAt
resumeError = e.message
}
}
}
// Nothing to do when the name has not changed, so the button says so rather than sending a
// request whose success would look exactly like the failure of having typed nothing.
val changed = name.trim().isNotEmpty() && name.trim() != title
fun save() {
if (!changed || saving) return
val chosen = name.trim()
saving = true
error = null
scope.launch {
try {
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
onRenamed(chosen)
} catch (e: ApiException) {
// Reported here, where it happened, because this dialog is the only place that
// knows a rename was attempted.
error = e.message
saving = false
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Session settings") },
text = {
// Scrollable, because this dialog grew past a screenful: a Material dialog constrains
// its own height and clips what does not fit, so the last control on the list is one
// large system font away from being unreachable with nothing on screen to say so.
Column(Modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !saving,
modifier = Modifier.fillMaxWidth(),
// The keyboard's own action does what the button does: a one-field form where
// the return key does nothing is a form people press return at anyway.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { save() }),
)
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("Notifications", modifier = Modifier.weight(1f))
if (notify == null && notifyError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = notify == true,
onCheckedChange = { setNotify(it) },
enabled = notify != null,
)
}
// Beside the switch that failed, not with the rename's error: they are two requests
// and a reader has to be able to tell which one the server refused.
notifyError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
if (autoResume == null && resumeError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = autoResume == true,
onCheckedChange = { setAutoResume(it, resumeMessage) },
enabled = autoResume != null,
)
}
// Disabled rather than hidden while the switch is off: a field that comes and goes
// makes its own presence the signal, and a visible one teaches what the switch will
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
// sentence is one request instead of one per letter.
OutlinedTextField(
value = resumeMessage,
onValueChange = { resumeMessage = it },
label = { Text("Message to send") },
// What an empty field means, in the field: the server's own word rather than a
// session poked with nothing to read.
placeholder = { Text(DEFAULT_RESUME_MESSAGE) },
singleLine = true,
enabled = autoResume == true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
)
// What it does and what it costs, in the order it happens. The last sentence is the
// one that matters: the time below is when the server will *ask*, not a promise
// about when the session speaks.
Text(
"When this session stops because the account is out of quota, the server " +
"checks the limit and sends this message once it has lifted. It checks " +
"again if the limit is still on.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Only where something is actually waiting. Absent is not a state worth a row: a
// session that has not hit a limit has nothing scheduled, which the reader can see
// from the switch.
resumeAt?.let { at ->
Text(
"Waiting now -- next check ${formatCheckTime(at)}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
resumeError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = typedCwd,
onValueChange = { typedCwd = it },
label = { Text("Working directory") },
// What the field cannot say by being empty: a session that was never given
// one starts wherever its launcher does, and this names that rather than
// showing a path nobody chose.
placeholder = { Text("wherever the session was started") },
singleLine = true,
enabled = cwd != null && !movingCwd,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
)
TextButton(
onClick = { moveCwd() },
enabled =
cwd != null &&
!movingCwd &&
typedCwd.trim().isNotEmpty() &&
typedCwd.trim() != cwd,
) {
Text(if (movingCwd) "Moving..." else "Move")
}
}
// The whole of what pressing Move does, where it is about to be pressed. A
// directory is settled when the process is spawned, so it is ended and the next
// thing said to the session starts it in the new place.
Text(
"Moving stops the session's process. It starts again in the new directory " +
"with the next message, or with Start.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
cwdError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
// Left out rather than disabled, the one place this dialog does that: a disabled
// control teaches what the thing can do, and a llama session cannot do this at all
// -- the row would be teaching something false about it.
if (takesEffort) {
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Thinking", modifier = Modifier.weight(1f))
PickerButton(
current = level ?: DEFAULT_EFFORT,
// The level the CLI picks for itself is in the list as well as in the
// button, so leaving a level is not a one-way trip -- the same
// correction the model picker carries.
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
onPick = { chosen ->
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
},
)
}
// What it costs, said where it is about to be pressed, like Move above: the
// CLI reads the level when it launches and has no control request for
// changing one.
Text(
"Changing this stops the session's process. It starts again with the " +
"next message, or with Start.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
effortError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Transcript", modifier = Modifier.weight(1f))
// The size is what the button discards, and the unknown state is drawn rather
// than guessed: a spinner while the directory is being measured, and words when
// there is nothing there, because "nothing cached" and "0 B" read as different
// claims.
when {
cachedBytes == null ->
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
else ->
Text(
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(12.dp))
// Enabled whether or not anything is cached: "what I see disagrees with the
// machine" is a state an empty cache can be in too, and a control that comes
// and goes makes its own presence the signal.
TextButton(onClick = onReload) { Text("Reload") }
}
// Captioned, unlike the controls above it, for the same reason Move is: what it
// costs is not visible, and neither is the case it exists for.
Text(
"Reload throws away this phone's copy and fetches the transcript from the " +
"server again. Use it when what is shown here disagrees with the file " +
"on the machine.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
// About this session, which is what everything in here is -- and it was on the
// header until 2026-09-03, where the folder button now is. It copies rather than
// opening anything, so it says so and then says it happened: a row that looks like
// a control and gives no sign of having run is one people press twice.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("Render timings", modifier = Modifier.weight(1f))
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
}
// Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because
// ui-trace and the emulator smoke run find it by that label, the same way every
// other control here is found -- see AGENTS.md's "Driving the UI".
onRunBenchmark?.let { run ->
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("P0 benchmark", modifier = Modifier.weight(1f))
TextButton(onClick = run) { Text("Run benchmark") }
}
}
}
},
// Disabled rather than absent while there is nothing to save: a button that comes and goes
// makes its own presence the signal, and its absence cannot say why.
confirmButton = {
TextButton(onClick = { save() }, enabled = changed && !saving) {
Text(if (saving) "Saving..." else "Save")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}
/**
* When the server will next look, as a local time.
*
* A time rather than a countdown, for the reason the transcript's own limit row gives: this screen
* reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody
* is looking at it.
*/
private fun formatCheckTime(epochSeconds: Double): String =
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochSecond(epochSeconds.toLong()))
} catch (_: Exception) {
// A time that cannot be read is not a time to show: the sentence above still says a check
// is coming, which is the part the reader can act on.
"soon"
}
@@ -1,273 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import java.time.Duration
import java.time.OffsetDateTime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/** What one machine's rate limits came back as, or why they didn't. */
sealed class SessionUsage {
/** Nothing has come back yet. Distinct from every answer, including an empty one. */
data object Waiting : SessionUsage()
/** Every window the machine reported, in the order it reported them. */
data class Known(val windows: List<UsageWindow>) : SessionUsage()
/**
* This machine meters nothing, so there is no window to show.
*
* Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a
* local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a
* failure to find something out. The backend never asks such a machine, and reading that
* silence as "couldn't find out" is answering with the nearest available word.
*/
data object NotMetered : SessionUsage()
/**
* The question could not be answered, and why.
*
* Its own state because "we couldn't find out" and "none of it is used" must never share an
* appearance: a bar sitting at zero because a machine is unreachable reads as plenty of
* headroom.
*/
data class Unavailable(val why: String) : SessionUsage()
}
/** How often to ask again. The backend caches, so this re-reads its cache rather than the API. */
private const val REFRESH_MS = 60_000L
/**
* One poll of every machine's limits, and the handle to ask again.
*
* A screen shows this answer in more than one place -- the bar under the session header, the colour
* of the button beside it, and the dialog that button opens -- and each used to fetch for itself.
* Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval
* old when the dialog opens with a fresh one, so the header read 42% while the screen over it read
* 47%.
*/
class UsageFeed(
val snapshots: LoadState<List<UsageSnapshot>>,
/** A fetch is outstanding. Only ever true over an answer already shown. */
val refreshing: Boolean,
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
val refresh: () -> Unit,
) {
/**
* What meters [session], and what that meter came back as. See [usageFor] for the states.
*
* A session rather than a machine, because a machine is not what is metered: one machine runs
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
*/
fun forSession(session: SessionSummary): SessionUsage {
// Settled without asking anybody: a session nothing meters has nothing to check, and
// "checking" is what the fetch's own states would say about it for as long as one is out.
val provider = session.usageProvider ?: return SessionUsage.NotMetered
return when (val state = snapshots) {
is LoadState.Loading -> SessionUsage.Waiting
is LoadState.Error -> SessionUsage.Unavailable(state.message)
is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
}
}
}
/**
* The one poll of the machines' rate limits, polled and refreshable.
*
* Hoisted out of [SessionUsageBar] because everything on a session's screen that reports on usage
* has to be reporting the same measurement; see [UsageFeed].
*/
@Composable
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
var refreshing by remember { mutableStateOf(true) }
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also
// resets the countdown rather than leaving one due immediately after.
var asked by remember { mutableIntStateOf(0) }
LaunchedEffect(asked) {
while (true) {
refreshing = true
// Replaces the answer only once the next one is in hand: dropping back to Loading would
// blank a bar somebody is reading for the length of a round trip, and what was on
// screen is still the last thing the machine actually said.
snapshots =
try {
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
} catch (e: ApiException) {
LoadState.failed(e)
}
refreshing = false
delay(REFRESH_MS)
}
}
return remember(snapshots, refreshing) { UsageFeed(snapshots, refreshing) { asked++ } }
}
/**
* The colour for a control that reports on [usage] as a whole: the worst window's.
*
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
* Taken over however many windows came back rather than the three Claude sends today -- the backend
* passes windows it does not recognise straight through.
*
* Every state that is not a measurement takes the ordinary control colour instead. That is the
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
* unknown blue would say "measured, and fine" about a machine nobody could reach.
*/
@Composable
fun usageGlyphColour(usage: SessionUsage): Color =
when (usage) {
is SessionUsage.Known ->
usage.windows.maxOfOrNull { it.percent }?.let { quotaColor(it) }
?: MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.primary
}
/**
* The five-hour window for the machine this session runs on, under the session's own header.
*
* Here rather than only in the usage dialog because it is the number that decides whether to keep
* going, and it was a screen away from the place that decision gets made. It reports on this
* session's machine alone -- the dialog is still where every machine is compared.
*
* What it shows is the paid service's own metering, never derived from what this app has watched go
* past: the transcript's token counts are a different quantity, measured differently, and a bar
* built out of them would be a guess wearing a measurement's clothes.
*/
@Composable
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
DebugStats.count("usage bar recomposed")
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
// would sit at a stale figure for hours.
var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(REFRESH_MS)
now = OffsetDateTime.now()
}
}
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// a problem about a setup somebody chose, on every screen, forever.
//
// And nothing while the first fetch is out, which is a different silence. A request in flight
// is not a state to report -- and the session that meters nothing is exactly the one this
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
// second and was then taken away. A row that has to be withdrawn is worse than one that
// arrives late.
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
return
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp),
) {
// Words, not a colour and not an empty bar: every one of these is a different kind of
// answer from "this much is used", and only words carry a difference in kind.
when (val state = usage) {
// Both handled above, before the row exists at all.
SessionUsage.NotMetered,
SessionUsage.Waiting -> Unit
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
is SessionUsage.Known -> {
val window = state.windows.firstOrNull { it.kind == "session" }
if (window == null) {
UsageNote("5-hour usage unknown -- no five-hour window reported")
} else {
LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
// The same step at the same percentages as the dialog's bars: this is the
// same measurement, and a reader who learned the colour there has to be
// able to read it here without checking which screen they are on.
color = quotaColor(window.percent),
modifier = Modifier.weight(1f),
)
Text(
fiveHourLabel(window, now),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
}
/** Anything this row says instead of drawing a bar, so all of them look the same. */
@Composable
private fun UsageNote(text: String) {
Text(
text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/**
* "42% -- 2h 15m left": how much is gone, then how long what is left has to last.
*
* The percentage on its own does not answer the question it gets asked, which is whether to start
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
*
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
* that is not running gets the percentage and nothing else.
*/
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%"
return when (val end = windowEnd(window.resetsAt, now)) {
// Between blocks the five-hour window has no reset time, and saying so is a fact about
// nothing: there is no window to run out. The percentage is the whole answer.
WindowEnd.NotRunning -> percent
WindowEnd.Unreadable -> "$percent · reset time unreadable"
is WindowEnd.Ends ->
// Under a minute, including past the end: the number would round to "0m left", which
// reads as a measurement rather than as the window having run out.
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
else "$percent · ${formatSpan(end.until)} left"
}
}
/**
* One meter's snapshot, out of every machine's: [setup]'s row for [provider].
*
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
* service on one machine.
*
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered.
*/
fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
// No snapshot at all means the backend never asked, which it only does where there is nothing
// to ask about. That is a different answer from having asked and failed.
val mine =
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
?: return SessionUsage.NotMetered
if (mine.state != "ok") {
return SessionUsage.Unavailable(mine.detail ?: mine.state)
}
return SessionUsage.Known(mine.windows)
}
@@ -1,197 +0,0 @@
package com.example.aiapp
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.example.wgapplink.EnrollmentScanActivity
import com.google.zxing.client.android.Intents
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanIntentResult
import com.journeyapps.barcodescanner.ScanOptions
/**
* Server address and token. The normal path is the "Scan QR code" button below, which decodes the
* server's terminal QR itself; these fields are the fallback for typing the same three values by
* hand. [onBack] is null on first run, when there is nothing to go back to.
*/
@Composable
fun SettingsScreen(
existing: ServerSettings?,
onSaved: (ServerSettings) -> Unit,
onBack: (() -> Unit)?,
) {
val context = LocalContext.current
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
// Never pre-filled from the stored token: this screen shouldn't be a way to read the credential
// back off the device.
var token by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val scanLauncher =
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
// Null contents means the user backed out of the scanner -- not an error.
val contents = result.contents ?: return@rememberLauncherForActivityResult
val settings = parseEnrollmentUri(contents.toUri())
if (settings == null) {
error = "Not a valid enrollment code"
} else {
saveServerSettings(context, settings)
onSaved(settings)
}
}
val requestCamera =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
scanLauncher.launch(enrollmentScanOptions())
} else {
error =
"Scanning needs the camera. Grant it in the system settings, " +
"or type the host, port and token in below."
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
// Leading, where a back arrow points at what it returns to. Trailing it would put a
// left-pointing arrow at the right edge, aimed across the title it sits beside.
//
// Absent rather than disabled on first run, which is the one place this app lets a
// control come and go: there is no screen underneath yet, so a Back here would not be a
// capability being withheld but a promise it could not keep.
if (onBack != null) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
}
Text(
"Server",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(8.dp))
Text(
"The easy way: run ai-server on the backend and scan the QR it prints. " +
"Or type the same values here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(
onClick = {
// Hold the camera permission before the scanner starts. Letting its activity ask on
// our behalf is what the library does by default, and it opens the camera without
// waiting for the answer: the first-ever scan comes up as a live preview with
// "Sorry, the Android camera encountered a problem" over it, and works on the
// second try.
if (
context.checkSelfPermission(Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
) {
scanLauncher.launch(enrollmentScanOptions())
} else {
requestCamera.launch(Manifest.permission.CAMERA)
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Scan QR code")
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text("Port") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp))
error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
Button(
onClick = {
val portNumber = port.trim().toIntOrNull()
val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" }
when {
host.isBlank() -> error = "Host is required"
portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535"
effectiveToken.isEmpty() ->
error = "Token is required -- scan the server's QR or paste it"
else -> {
val settings = ServerSettings(host.trim(), portNumber, effectiveToken)
saveServerSettings(context, settings)
onSaved(settings)
}
}
}
) {
Text("Save")
}
}
}
/**
* How the enrollment QR is scanned, in one place because two callers reach it -- straight from the
* button when the camera permission is already held, and from the permission result when it has
* just been granted.
*
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark-
* themed terminal it comes out as a photographic negative the scanner silently never matches. The
* mixed decoder alternates normal and inverted frames, costing half the frame rate at each
* polarity.
*/
private fun enrollmentScanOptions(): ScanOptions =
ScanOptions()
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
.setCaptureActivity(EnrollmentScanActivity::class.java)
// Follow the phone, not the library's landscape pin.
.setOrientationLocked(false)
.addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)
@@ -1,410 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The machines this backend can run things on.
*
* Note what this screen cannot do: name a program. Providers are what the server found when it
* asked the machine, so adding one is "here is how to reach it" and never "here is what to run" --
* which is what keeps the enrolled token from being able to introduce commands.
*/
@Composable
fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var adding by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<Setup?>(null) }
var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
LaunchedEffect(reloadToken) { reload() }
Column(Modifier.fillMaxSize().padding(16.dp)) {
// The heading and Back are the tab row's now; adding a machine is this tab's own work and
// stays with the list it adds to.
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { adding = true }) { Text("Add machine") }
}
Spacer(Modifier.height(8.dp))
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
busy?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
Text(it, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
}
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
LazyColumn(Modifier.fillMaxSize()) {
uniqueItems(current.value, key = { it.id }) { setup ->
SetupCard(
setup = setup,
onRename = { renaming = setup },
onRediscover = {
scope.launch {
busy = "Asking ${setup.name} what it has…"
actionError =
runCatching {
withContext(Dispatchers.IO) {
updateSetup(
settings,
setup.id,
rediscover = true,
)
}
}
.exceptionOrNull()
?.message
busy = null
reload()
}
},
onDelete = { confirmingDelete = setup },
)
}
}
}
}
if (adding) {
AddSetupDialog(
onDismiss = { adding = false },
onAdd = { name, ssh ->
adding = false
scope.launch {
busy = "Asking $name what it has…"
actionError =
runCatching {
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
}
.exceptionOrNull()
?.message
busy = null
reload()
}
},
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
)
}
renaming?.let { setup ->
RenameDialog(
setup = setup,
onDismiss = { renaming = null },
onRename = { name ->
renaming = null
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
updateSetup(settings, setup.id, name = name)
}
}
.exceptionOrNull()
?.message
reload()
}
},
)
}
confirmingDelete?.let { setup ->
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Remove \"${setup.name}\"?") },
text = {
Text(
"The machine is left alone -- this only stops this app offering it. " +
"Sessions still running on it must be deleted first."
)
},
confirmButton = {
TextButton(
onClick = {
confirmingDelete = null
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
}
.exceptionOrNull()
?.message
reload()
}
}
) {
Text("Remove")
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
},
)
}
}
@Composable
private fun SetupCard(
setup: Setup,
onRename: () -> Unit,
onRediscover: () -> Unit,
onDelete: () -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(setup.name, style = MaterialTheme.typography.titleSmall)
Text(
// Not "this machine": the seeded setup is *called* that, and the card read "this
// machine / this machine".
setup.address ?: "runs where the backend does",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
if (setup.providers.isEmpty()) {
"Nothing found on it. Install something and rediscover."
} else {
setup.providers.joinToString(" · ") { it.name }
},
style = MaterialTheme.typography.bodySmall,
)
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onRename) { Text("Rename") }
TextButton(onClick = onRediscover) { Text("Rediscover") }
Spacer(Modifier.weight(1f))
TextButton(onClick = onDelete) { Text("Remove") }
}
}
}
}
@Composable
private fun AddSetupDialog(
onDismiss: () -> Unit,
onAdd: (String, SshDetails?) -> Unit,
onTest: suspend (SshDetails?) -> List<Provider>,
) {
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") }
var identity by remember { mutableStateOf("") }
var attachmentsDir by remember { mutableStateOf("") }
var modelsDir by remember { mutableStateOf("") }
var tested by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) }
fun details(): SshDetails? =
address
.trim()
.takeIf { it.isNotEmpty() }
?.let { typed ->
val (host, typedPort) = splitHostAndPort(typed)
SshDetails(
address = host,
port = typedPort,
identityFile = identity.trim().ifEmpty { null },
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
modelsDir = modelsDir.trim().ifEmpty { null },
)
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add a machine") },
text = {
Column {
Text(
"Leave the address blank for the machine the backend runs on. " +
"What it can run is discovered, not typed.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
)
OutlinedTextField(
value = address,
onValueChange = { address = it },
// Just the shape. What a blank one means is said once, in the text above this
// form -- repeating it here wrapped the label onto a second line.
label = { Text("user@host[:port]") },
singleLine = true,
)
OutlinedTextField(
value = identity,
onValueChange = { identity = it },
label = { Text("Key path on the backend") },
singleLine = true,
)
// Where a file attached from the phone lands on that machine. Blank means the
// session's own directory, which is what most people want.
OutlinedTextField(
value = attachmentsDir,
onValueChange = { attachmentsDir = it },
label = { Text("Folder for attached files (optional)") },
singleLine = true,
)
// Where that machine's GGUFs are, for a llama.cpp session on it. Blank means
// the same place this backend keeps its own downloads, read on that machine.
OutlinedTextField(
value = modelsDir,
onValueChange = { modelsDir = it },
label = { Text("Folder for models (optional)") },
singleLine = true,
)
tested?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
},
confirmButton = {
TextButton(enabled = name.isNotBlank(), onClick = { onAdd(name.trim(), details()) }) {
Text("Add")
}
},
dismissButton = {
Row {
// Tried before saving, so a wrong address or an unauthorised key is caught while
// this form is still on screen rather than at the first spawn.
TextButton(
enabled = !testing,
onClick = {
testing = true
tested = "Asking…"
scope.launch {
tested =
runCatching { onTest(details()) }
.fold(
onSuccess = { found ->
if (found.isEmpty()) {
"Reached it, but found nothing it can run."
} else {
"Found ${found.joinToString(", ") { it.name }}"
}
},
onFailure = { it.message ?: "Couldn't reach it" },
)
testing = false
}
},
) {
Text("Test")
}
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}
@Composable
private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var name by remember { mutableStateOf(setup.name) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
)
Spacer(Modifier.height(8.dp))
Text(
"Sessions already running on it keep working -- they refer to the machine, " +
"not to what it is called.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(enabled = name.isNotBlank(), onClick = { onRename(name.trim()) }) {
Text("Rename")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
/**
* Splits `user@host:port` into its two halves, with the port left null when none was typed.
*
* One field rather than two because that is how an address is written and read everywhere else, and
* because a port that is almost always 22 does not deserve a box of its own on a phone keyboard.
* Null rather than 22: the backend already decides the default.
*
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
* `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon
* followed by digits.
*/
private fun splitHostAndPort(typed: String): Pair<String, Int?> {
if (typed.startsWith("[")) {
val close = typed.indexOf(']')
if (close > 0) {
val host = typed.substring(1, close)
val rest = typed.substring(close + 1)
val port = rest.removePrefix(":").toIntOrNull().takeIf { rest.startsWith(":") }
return host to port
}
}
if (typed.count { it == ':' } == 1) {
val host = typed.substringBeforeLast(':')
val port = typed.substringAfterLast(':').toIntOrNull()
if (port != null && host.isNotEmpty()) return host to port
}
return typed to null
}
@@ -1,44 +0,0 @@
package com.example.aiapp
import android.content.Intent
import android.net.Uri
import androidx.core.content.IntentCompat
/**
* What another app handed this one through the share sheet, waiting to be attached to a session.
*
* Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the
* share arrives before anyone has said which. [serial] makes two shares of the same thing two
* requests, for the reason [SessionOpenRequest] carries one.
*/
data class ShareRequest(val uris: List<Uri>, val text: String?, val serial: Int)
/** The share in [intent], or null when it is some other intent. */
fun sharedContent(intent: Intent, serial: Int): ShareRequest? {
val uris =
when (intent.action) {
Intent.ACTION_SEND ->
listOfNotNull(
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
)
Intent.ACTION_SEND_MULTIPLE ->
IntentCompat.getParcelableArrayListExtra(
intent,
Intent.EXTRA_STREAM,
Uri::class.java,
)
.orEmpty()
else -> return null
}
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() }
if (uris.isEmpty() && text == null) return null
return ShareRequest(uris, text, serial)
}
/** What is waiting, for the banner that says so. */
fun ShareRequest.summary(): String =
when {
uris.size == 1 -> "1 file to attach"
uris.isNotEmpty() -> "${uris.size} files to attach"
else -> "Text to attach"
}
@@ -1,20 +0,0 @@
package com.example.aiapp
/**
* A byte count at the coarsest unit that still says something, so rows stay comparable.
*
* Null at zero and below, because the screens that ask disagree about what nothing means and only
* the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no
* bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached
* transcript says "nothing cached", because a figure of none would read as a measurement.
*
* Its own file rather than the import screen's, where it started: three screens now say a size, and
* a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
*/
fun humanSize(bytes: Long): String? =
when {
bytes <= 0L -> null
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
bytes >= 1_000L -> "${bytes / 1_000L} kB"
else -> "$bytes B"
}
@@ -1,386 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The spawn screen: what to run, where to run it, and the per-kind fields.
*
* Providers and hosts both come from the server, so adding either to its config.ron shows up here
* with no app rebuild.
*/
@Composable
fun SpawnScreen(
settings: ServerSettings,
onSpawned: (SessionSummary) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
// What the form is made of, and whether we have it yet. A failure here is not the same as a
// server with nothing to offer, so it must not reach the pickers as empty lists.
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
// Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the
// provider is stored by name and resolved against the current setup rather than held as an
// object that could outlive the list it came from.
var setupName by remember { mutableStateOf<String?>(null) }
var providerName by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") }
var cwd by remember { mutableStateOf("") }
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
var permissionMode by remember { mutableStateOf("auto") }
// Null until the server has been asked, and null again if it answers "no level chosen" -- the
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer
// arrives is one you can spawn at without having chosen it.
var effort by remember { mutableStateOf<String?>(null) }
var defaultsAsked by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
// form worth keeping, and that one leaves nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) }
// The models on the *chosen machine*, for a llama provider to choose between. Kept separate
// from the setups: a Claude session needs none, so failing to list them must not stop the
// screen rendering. Refetched when the machine changes, because a model is a file on one
// machine -- see [fetchSetupModels].
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") }
var temperature by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
// Separate from the setups fetch below and deliberately not fatal: failing to learn the
// default must leave a screen you can still spawn from, so the picker stays on "default"
// and says so rather than the whole form refusing to draw.
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
.onSuccess { effort = it }
defaultsAsked = true
options =
try {
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
val first = fetched.firstOrNull()
setupName = first?.name
providerName = first?.providers?.firstOrNull()?.name
LoadState.Loaded(fetched)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
"New session",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Cancel") }
}
Spacer(Modifier.height(16.dp))
// Nothing below is fillable until the options are here, and a failure to fetch them leaves
// no form worth showing -- so this reports and stops, rather than offering empty pickers
// under an error message.
val setups =
when (val state = options) {
is LoadState.Loading -> {
CircularProgressIndicator()
return@Column
}
is LoadState.Error -> {
Text(state.message, color = MaterialTheme.colorScheme.error)
return@Column
}
is LoadState.Loaded -> state.value
}
val setup = setups.firstOrNull { it.name == setupName }
// Whichever machine is chosen now, asked again when that changes. The old machine's list
// is dropped first rather than left on screen: a file name from another machine looks
// exactly like one from this one.
LaunchedEffect(setup?.id) {
models = emptyList()
modelKey = null
val id = setup?.id ?: return@LaunchedEffect
models =
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
.getOrDefault(emptyList())
}
val current = setup?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and permission modes; keying the
// extra fields on the kind rather than the provider name keeps a second Claude provider
// from needing anything here.
val isClaude = current?.kind == "claude_cli"
val isLlama = current?.kind == "llama_cpp"
// The machine first, because it decides what can be run at all.
ChipGroup(
label = "Setup",
options = setups.map { it.name },
selected = setupName,
onSelect = { name ->
setupName = name
// The provider list changes with the machine, so a name carried over from the
// previous one would be a selection that isn't in the picker. Take that machine's
// first.
providerName =
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
},
)
setup?.address?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// The address belongs to the setup above it, not to the provider label below; without
// this they read as one block.
Spacer(Modifier.height(8.dp))
}
// Only what this machine actually has. A setup with none says so rather than showing an
// empty row that reads as a failure.
if (setup != null && setup.providers.isEmpty()) {
Text(
"\"${setup.name}\" has no providers configured.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Provider",
options = setup?.providers?.map { it.name }.orEmpty(),
selected = providerName,
onSelect = { providerName = it },
)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (isLlama) {
// A llama session names one of the models on the machine it will run on, so the
// choice is that list rather than free text -- a name that is not on that machine's
// disk is a session that cannot start.
if (models.isEmpty()) {
Text(
"No models on ${setup?.name ?: "this machine"}. The Models screen downloads " +
"to the backend; another machine needs the file put there itself.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Model",
// The file, not the whole key: the repository is the same for every
// quantisation of a model, so the file name is what tells two of them apart.
options = models.map { it.file },
selected = models.firstOrNull { it.key == modelKey }?.file,
onSelect = { file -> modelKey = models.first { it.file == file }.key },
)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = contextSize,
onValueChange = { contextSize = it },
label = { Text("Context size (blank = the model's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = temperature,
onValueChange = { temperature = it },
label = { Text("Temperature (blank = llama.cpp's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
}
if (isClaude) {
if (current.models.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
ChipGroup(
label = "Model",
options = current.models,
selected = model.ifEmpty { null },
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
)
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = model,
onValueChange = { model = it },
label = { Text("Model (blank = the CLI's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = cwd,
onValueChange = { cwd = it },
label = { Text("Working directory") },
placeholder = { Text("/home/…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
ChipGroup(
label = "Permissions",
options = PERMISSION_MODES,
selected = permissionMode,
onSelect = { permissionMode = it },
)
Spacer(Modifier.height(16.dp))
// Says what it does to *later* spawns as well, because it does: the level chosen here
// is stored as the default, which is the whole way that default is set. A picker that
// quietly changed a global would be the same control with the fact left out.
ChipGroup(
label = "Thinking (kept as the default for new sessions)",
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
// The CLI's own default is a level in the list, so this cannot be a one-way trip.
// Disabled-looking until the server has answered, for the reason above.
selected = if (defaultsAsked) effort ?: DEFAULT_EFFORT else null,
onSelect = { chosen -> effort = chosen.takeIf { it != DEFAULT_EFFORT } },
)
}
Spacer(Modifier.height(24.dp))
// Beside the button that produced it.
spawnError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
Button(
onClick = {
val chosen = current ?: return@Button
busy = true
scope.launch {
try {
val spawned =
withContext(Dispatchers.IO) {
// Stored before the spawn and not after it: choosing a level is
// an intent about new sessions in general, so a spawn that then
// fails must not also lose the choice. Non-fatal for the same
// reason the fetch above is -- the session is what was asked for.
if (isClaude) {
runCatching { setDefaultEffort(settings, effort) }
}
spawnSession(
settings,
// The id, not the label: labels are editable and the server
// resolves by id. Non-null here, since `chosen` came from
// `setup`'s own provider list.
setup = setup.id,
provider = chosen.name,
title = title.trim(),
model =
if (isLlama) modelKey else model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude },
effort = effort.takeIf { isClaude },
// Sent only when set, so blank means "whatever llama.cpp does
// by default" rather than a zero.
params =
buildMap {
if (isLlama) {
contextSize
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("contextSize", it) }
temperature
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("temperature", it) }
}
},
)
}
onSpawned(spawned)
} catch (e: ApiException) {
spawnError = e.message
busy = false
}
}
},
enabled = !busy && current != null && !(isLlama && modelKey == null),
) {
Text(if (busy) "Spawning..." else "Spawn")
}
}
}
/**
* A labeled row of choices that wraps onto as many lines as it needs.
*
* FlowRow rather than Row: a plain Row gives every chip an equal share of a single line, so once
* the options don't fit, the text inside each one wraps to one character per line instead of the
* row wrapping.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ChipGroup(
label: String,
options: List<String>,
selected: String?,
onSelect: (String) -> Unit,
) {
Text(label, style = MaterialTheme.typography.labelLarge)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
options.forEach { option ->
FilterChip(
selected = selected == option,
onClick = { onSelect(option) },
label = { Text(option) },
)
}
}
}
@@ -1,100 +0,0 @@
package com.example.aiapp
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/**
* How long to wait before opening a dropped stream again.
*
* Shared by every screen that follows one, so a reconnect is not paced differently depending on
* which stream dropped. Short enough that a tunnel coming back is not noticed, long enough that a
* server which is genuinely down is not being asked several times a second.
*/
const val RECONNECT_DELAY_MS = 1500L
/**
* One server-sent-events connection, framed.
*
* The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank
* line ends the frame, comments start with `:`, and a frame is either named with no payload or a
* payload with no name. Two screens follow two different streams and neither should re-derive that.
*
* Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the
* cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns
* rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where
* to resume from.
*/
class Sse(private val settings: ServerSettings) {
@Volatile private var connection: HttpURLConnection? = null
@Volatile private var closed = false
fun close() {
closed = true
connection?.disconnect()
}
/**
* Follows the stream at [path], handing each frame to [onFrame] as its name (null for an
* ordinary data frame) and its payload. The path is given here rather than at construction
* because a caller that reconnects usually resumes from somewhere new.
*
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
* stream is live, and the only honest thing to clear a previous failure on: clearing on the
* first *event* instead left an idle stream displaying an error it had already recovered from.
*/
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
// Opening is inside the try, not before it. Everything this method can fail at owes the
// caller the same kind of failure, and a connection that could not even be constructed used
// to escape as a raw `IOException` from a line no `catch` covered.
var connection: HttpURLConnection? = null
try {
connection =
(URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection).also {
this.connection = it
}
connection.applyPinnedTls()
connection.connectTimeout = CONNECT_TIMEOUT_MS
// No read timeout: between events there is nothing to read for as long as the thing
// being followed is idle; the server's keep-alives and a dead socket erroring out are
// the liveness story.
connection.readTimeout = 0
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
connection.setRequestProperty("Accept", "text/event-stream")
if (connection.responseCode != 200) {
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream")
}
onOpen()
val reader = connection.inputStream.bufferedReader()
val data = StringBuilder()
var name: String? = null
while (true) {
val line = reader.readLine() ?: break
when {
line.isEmpty() -> {
if (name != null || data.isNotEmpty()) onFrame(name, data.toString())
data.clear()
name = null
}
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
line.startsWith("event:") -> name = line.removePrefix("event:").trim()
else -> {} // id:, comments -- nothing to do
}
}
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
if (!closed) {
throw ApiException(
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
cause = e,
)
}
} finally {
connection?.disconnect()
this.connection = null
}
}
}
@@ -1,336 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Catppuccin Mocha, as published in `catppuccin/palette`.
*
* Named rather than used as literals at the point of need, so the mapping below reads as the
* decision it is -- "a card is Surface 0" -- and so a value can be checked against the upstream
* palette without reading the layout that uses it.
*/
private object Mocha {
val Rosewater = Color(0xFFF5E0DC)
val Mauve = Color(0xFFCBA6F7)
val Red = Color(0xFFF38BA8)
val Peach = Color(0xFFFAB387)
val Yellow = Color(0xFFF9E2AF)
val Green = Color(0xFFA6E3A1)
val Teal = Color(0xFF94E2D5)
val Sky = Color(0xFF89DCEB)
val Blue = Color(0xFF89B4FA)
val Lavender = Color(0xFFB4BEFE)
val Pink = Color(0xFFF5C2E7)
val Text = Color(0xFFCDD6F4)
val Subtext1 = Color(0xFFBAC2DE)
val Subtext0 = Color(0xFFA6ADC8)
val Overlay0 = Color(0xFF6C7086)
val Surface2 = Color(0xFF585B70)
val Surface1 = Color(0xFF45475A)
val Surface0 = Color(0xFF313244)
val Base = Color(0xFF1E1E2E)
val Mantle = Color(0xFF181825)
val Crust = Color(0xFF11111B)
}
/**
* The app's colour scheme: Catppuccin Mocha mapped onto Material's roles.
*
* Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link*
* -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike
* is a preference, not a contract.
*
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
* Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside
* it, and a project's card is Surface 0: one visible step up, which is the whole of what the
* nesting has to say.
*
* Accents on this palette are light, so anything filled with one takes Crust for its text.
*/
val AiAppColors =
darkColorScheme(
primary = Mocha.Mauve,
onPrimary = Mocha.Crust,
primaryContainer = Mocha.Surface1,
onPrimaryContainer = Mocha.Mauve,
secondary = Mocha.Lavender,
onSecondary = Mocha.Crust,
secondaryContainer = Mocha.Surface1,
onSecondaryContainer = Mocha.Lavender,
tertiary = Mocha.Rosewater,
onTertiary = Mocha.Crust,
background = Mocha.Base,
onBackground = Mocha.Text,
surface = Mocha.Base,
onSurface = Mocha.Text,
surfaceVariant = Mocha.Surface0,
onSurfaceVariant = Mocha.Subtext0,
surfaceContainerLowest = Mocha.Crust,
surfaceContainerLow = Mocha.Mantle,
surfaceContainer = Mocha.Base,
surfaceContainerHigh = Mocha.Surface0,
surfaceContainerHighest = Mocha.Surface0,
inverseSurface = Mocha.Text,
inverseOnSurface = Mocha.Base,
inversePrimary = Mocha.Mauve,
outline = Mocha.Overlay0,
outlineVariant = Mocha.Surface2,
error = Mocha.Red,
onError = Mocha.Crust,
errorContainer = Mocha.Surface1,
onErrorContainer = Mocha.Red,
scrim = Mocha.Crust,
)
/**
* What a session is doing, said in colour.
*
* Here rather than beside each screen that shows a status. These were separate literals in two
* other files, so the same state was a slightly different colour depending which screen you looked
* at. A colour that carries meaning is part of the scheme, not a value typed where it was needed.
*/
val runningColor: Color
@Composable get() = Mocha.Green
/**
* "This went wrong on its own": a session that fell over.
*
* The scheme's error colour, and deliberately not "the same red as a destructive button" even
* though it is the same red. They are the same red for different reasons, and a state is not an
* action.
*/
val failedColor: Color
@Composable get() = MaterialTheme.colorScheme.error
/**
* About the session rather than about the task: a command, and the compaction one of them starts.
*
* Its own colour because it is its own kind of work. Everything else a session does is progress
* through what was asked of it; this is the session acting on itself, and none of it appears in the
* transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not
* replying to you either" has learned what distinguishes it from a session that has hung.
*/
val commandColor: Color
@Composable get() = Mocha.Blue
/**
* A clear: the conversation taken out of what the session is given.
*
* Red because of what it does, not because anything went wrong -- somebody asked for this, and a
* deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a
* third reason: this is neither a fault nor a button, it is the mark left where something was taken
* away. No two of the three can appear as the same kind of thing.
*/
val clearedColor: Color
@Composable get() = Mocha.Red
/** Waiting on a person: a question, a permission, a turn that is theirs. */
val awaitingColor: Color
@Composable get() = Mocha.Peach
/** Approaching a limit -- still fine, worth seeing. */
val warningColor: Color
@Composable get() = Mocha.Yellow
/**
* The fill of a progress bar that is only reporting how far along something is.
*
* Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary
* made it the loudest thing on a screen the reader opened to do something else. A download has no
* limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is
* [quotaColor].
*/
val progressColor: Color
@Composable get() = Mocha.Blue
/**
* The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red.
*
* One function rather than the same `when` written beside each bar, because the point of colouring
* by consequence is that the reader learns the step once. It reads as a difference in degree, which
* is all colour can carry: the states that differ in *kind* -- a window nobody could read, a
* machine that meters nothing -- are said in words elsewhere.
*
* [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent
* without one of them getting it wrong by a factor of a hundred.
*/
@Composable
fun quotaColor(percent: Double): Color =
when {
percent >= OVER_LIMIT_PERCENT -> overLimitColor
percent >= WARNING_PERCENT -> warningColor
else -> progressColor
}
/** Close enough to the limit to be worth seeing before starting something big. */
private const val WARNING_PERCENT = 75.0
/** Close enough that the next turn may be the one that is refused. */
private const val OVER_LIMIT_PERCENT = 90.0
/**
* The surface verbatim text sits on: a command, a tool's output, a code block in a reply.
*
* The darkest value in the palette rather than a step up from the page, and that is the point --
* everything else on this screen is somebody's prose, and this is what a machine was handed and
* what it said back, character for character. Crust sits *below* Base, so the same colour reads as
* one clear step down both on the page and on a card; a tint chosen upwards has to be picked twice
* and still collides with the card it lands on.
*
* One colour for all three, so "this is verbatim" is learnable once.
*/
val rawSurface: Color
@Composable get() = Mocha.Crust
/**
* Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette].
*
* Here with the rest of the palette rather than beside the code that highlights: the colours a
* fence is drawn in are the same accents every other coloured thing already uses.
*
* Not a composable, because [highlight] runs off the drawing thread; these never vary with the
* theme.
*/
fun catppuccinSyntax(): SyntaxPalette =
SyntaxPalette(
keyword = Mocha.Mauve,
string = Mocha.Green,
literal = Mocha.Peach,
comment = Mocha.Overlay0,
metadata = Mocha.Yellow,
punctuation = Mocha.Subtext0,
mark = Mocha.Sky,
)
/**
* The sixteen terminal colours, for what a Bash tool call printed; see [AnsiPalette].
*
* Catppuccin publishes its own ANSI mapping and this is it, rather than the eight accents picked by
* eye: a program printing in "colour 4" means blue, and which blue is a decision the palette has
* already made for every other blue on the screen.
*
* Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which
* is upstream's choice and not an omission here.
*
* The background is [rawSurface] because that is what a tool's output is drawn on, and reverse
* video needs to know what it is reversing against.
*/
fun ansiPalette(): AnsiPalette =
AnsiPalette(
colours =
listOf(
Mocha.Surface1,
Mocha.Red,
Mocha.Green,
Mocha.Yellow,
Mocha.Blue,
Mocha.Pink,
Mocha.Teal,
Mocha.Subtext1,
Mocha.Surface2,
Mocha.Red,
Mocha.Green,
Mocha.Yellow,
Mocha.Blue,
Mocha.Pink,
Mocha.Teal,
Mocha.Subtext0,
),
foreground = Mocha.Text,
background = Mocha.Crust,
)
/**
* What a selection looks like, stated rather than left to Material's default.
*
* The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app
* draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a
* code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of
* code looks like nothing happened even though it copies correctly.
*
* Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only
* as far as it takes to read on the darkest of them -- past this the fill starts competing with the
* syntax colours it sits behind.
*/
val AiAppSelectionColors =
TextSelectionColors(
handleColor = Mocha.Mauve,
backgroundColor = Mocha.Mauve.copy(alpha = 0.55f),
)
/**
* A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone.
*/
val linkColor: Color
@Composable get() = Mocha.Blue
/**
* A list's markers: the bullets and numbers down its left edge.
*
* The scheme's secondary accent rather than the text colour, because a marker is structure rather
* than words: coloured, the items of a list can be counted without reading them. Lavender is not
* one of the colours that mean something here, and it is the same at every depth, since depth is
* said by the glyph and the indent -- a colour per depth would make a difference in degree look
* like one in kind.
*/
val listMarkerColor: Color
@Composable get() = Mocha.Lavender
/** Past a limit. The scheme's error colour, for the reason [failedColor] gives. */
val overLimitColor: Color
@Composable get() = MaterialTheme.colorScheme.error
/**
* The composer's buttons, coloured by what pressing one does rather than by where it sits.
*
* Green makes something happen now, blue makes it happen later, orange takes back what is in
* flight, red ends the process. The near-collisions with the states above are deliberate: those are
* *states*, and these are *actions*. A reader never has to tell them apart, because nothing here is
* a state and nothing there is pressable.
*/
val sendColor: Color
@Composable get() = Mocha.Green
/** Sending while a turn runs: the message waits rather than starting one. See [sendColor]. */
val queueColor: Color
@Composable get() = Mocha.Blue
/**
* Interrupting the running turn: the work stops and the session stays.
*
* Orange rather than red because of how much it takes: only what is in flight. The process is still
* there holding the conversation. Red is spent on [stopColor], which is the same button in the same
* place when what it would end is the session's process.
*/
val pauseColor: Color
@Composable get() = Mocha.Peach
/** Ending the session's process -- the one button here that takes something away. */
val stopColor: Color
@Composable get() = Mocha.Red
/**
* Starting the process again, on the conversation it left.
*
* The same green as [sendColor] on purpose: both mean "this happens now", and they are never the
* same button -- the process button only offers to start when there is nothing running to stop.
*/
val startColor: Color
@Composable get() = Mocha.Green
/**
* A filled button in one of the action colours above.
*
* The content colour is stated here beside the fill rather than inherited. A semantic colour has to
* carry its own contrast: these fills are fixed whatever the surface under them does, so the theme
* will not change to rescue a foreground that stops being readable on one of them.
*/
@Composable
fun actionButtonColors(fill: Color): ButtonColors =
ButtonDefaults.buttonColors(containerColor = fill, contentColor = Mocha.Crust)
@@ -1,137 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import org.json.JSONObject
/**
* A tool call's input, read rather than dumped.
*
* Every tool's input arrives as JSON, and showing it raw makes the reader parse
* `{"command":"…","timeout":120000}` themselves to find the one line they care about. So the fields
* that carry the meaning are pulled out, and anything left over is still shown, because dropping a
* field would be claiming the tool has no other input when it might.
*/
data class ToolInput(
/** The thing that will actually be run or read, if this tool has one. */
val subject: String?,
/** The language [subject] is written in, for highlighting. */
val language: Language?,
/** The tool's own one-line summary, when it wrote one. */
val description: 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.
*/
val timeout: String?,
/** Everything else, as `name: value` lines. Never dropped. */
val rest: List<String>,
) {
/** The one line to show when there is only room for one: what this call is for. */
val title: String?
get() = description ?: subject
}
/**
* Which field of which tool is the subject.
*
* A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them
* from being the special case that gets its own code path. Unknown tools fall through to "no
* subject, everything is rest".
*/
private val SUBJECTS: Map<String, Pair<String, Language?>> =
mapOf(
"Bash" to ("command" to Language.SHELL),
"Read" to ("file_path" to null),
"Write" to ("file_path" to null),
"Edit" to ("file_path" to null),
"Glob" to ("pattern" to null),
"Grep" to ("pattern" to null),
"WebFetch" to ("url" to null),
)
/** Fields that are the tool's own prose about itself rather than input to it. */
private val DESCRIPTIONS = listOf("description", "prompt")
fun parseToolInput(tool: String, input: String): ToolInput {
val json =
try {
JSONObject(input)
} catch (_: org.json.JSONException) {
// Not an object: older transcripts and some tools send a bare string. It is still the
// input, so it is still shown.
return ToolInput(
null,
null,
null,
null,
input.takeIf { it.isNotBlank() }?.let { listOf(it) }.orEmpty(),
)
}
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
val subject = subjectKey?.let { json.optString(it) }?.takeIf { it.isNotBlank() }
val description = DESCRIPTIONS.firstNotNullOfOrNull {
json.optString(it).takeIf { v -> v.isNotBlank() }
}
val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
val rest =
json
.keys()
.asSequence()
.filter { it != subjectKey || subject == null }
.filter { it !in DESCRIPTIONS || description == null }
.filter { it != "timeout" || timeout == null }
.sorted()
.map { key -> "$key: ${json.get(key)}" }
.toList()
return ToolInput(subject, language, description, timeout, rest)
}
/**
* A tool call's input: its subject highlighted, then whatever else it carried.
*
* On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the
* call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a
* rendering fault.
*
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
*/
@Composable
fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
val parsed = remember(tool, input) { parseToolInput(tool, input) }
if (parsed.subject == null && parsed.rest.isEmpty()) return
RawBlock(modifier) {
parsed.subject?.let { subject ->
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely.
Text(
// Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it.
remember(subject, parsed.language) { highlight(subject, parsed.language) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
}
parsed.rest.forEach {
Text(
it,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
@@ -1,440 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
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
import androidx.compose.ui.unit.dp
/**
* One row as the transcript draws it: a run of consecutive tool calls, or anything else.
*
* Grouping is decided here rather than when events are folded, because it is a display decision:
* the transcript's own order is what paging and the event stream depend on, and one screen's idea
* of "these belong together" must not reach back into it.
*
* Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather
* than edited, and two rows describing the same events are equal. Compose infers stability from a
* class's fields, and a `List` field -- which several of these carry -- makes it assume the worst,
* so a page of history landing recomposed all 148 loaded rows including the markdown inside them,
* measured as 701 compositions for 148 rows in one scroll.
*
* The promise this makes is real and has to stay true: nothing here is mutated after it is built.
*/
@Immutable
sealed class TranscriptRow {
/**
* This row's identity in the list, which must survive everything that can happen to the row.
*
* The list is keyed by this so that inserting a new message at one end, or a page of history at
* the other, moves the rows and not the reader. When a key changes, the list loses its anchor
* and the transcript steps under whoever is reading it.
*
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
* that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
*/
abstract val key: Any
/**
* Where this row starts in the transcript: the sequence number of the oldest event behind it.
*
* Separate from [key], and deliberately so. [key] is the list's identity and is a display
* decision; a seq is the server's own numbering, assigned once and meaning the same thing to
* every device. So anything that has to point at a place in the conversation and still find it
* later -- a saved scroll position -- points with this.
*/
abstract val startSeq: Long
data class Single(val item: TranscriptItem) : TranscriptRow() {
override val key: Any
get() = item.key
override val startSeq: Long
get() = item.seq
}
/** Two or more calls with nothing between them; drawn as one collapsed card. */
data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() {
/** The run's own name, which every call in it already carries. */
val id: String
get() = calls.first().runId
override val key: Any
get() = id
override val startSeq: Long
get() = calls.first().seq
}
}
/**
* Runs of adjacent tool calls become one row; everything else passes through.
*
* A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words,
* and the run this exists for is the burst of five greps nobody wants to scroll past.
*/
fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> =
DebugStats.timed("grouped tool runs") { groupRuns(items) }
private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
val rows = mutableListOf<TranscriptRow>()
var run = mutableListOf<TranscriptItem.ToolRun>()
fun flush() {
when (run.size) {
0 -> {}
1 -> rows += TranscriptRow.Single(run.first())
else -> rows += TranscriptRow.Tools(run.toList())
}
run = mutableListOf()
}
items.forEach { item ->
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
// Adjacency is the same answer most of the time and a worse one at the edges: a call
// arriving next to an existing run, or a page of history arriving in front of one, both
// change which call is *first*.
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
run += item
} else {
flush()
if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item)
}
}
flush()
return rows
}
/**
* Several calls under one heading, closed until somebody asks.
*
* What says the calls belong together is the surface behind them, which is the one cue rather than
* two half-cues -- rounded to the same corner every other card in the app has, so a group reads as
* one object rather than as a square patch behind round things. The calls sit on it inset by
* [GROUP_INSET], which is the container's own padding rather than an indent.
*
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
* the run reads as one thing broken into its parts; see [connectedShape].
*
* It closes from either end. A long group's header scrolls off while its last call is still on
* screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the
* same height as the heading at the top.
*/
@Composable
fun ToolGroup(
group: TranscriptRow.Tools,
expanded: Boolean,
/**
* Where it was pressed is the row's business rather than the control's -- a group has a control
* at each end, and only the row knows where its own ends are.
*/
onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean,
onToolToggle: (String) -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit,
) {
val heading = "Called ${group.calls.size} tools"
if (!expanded) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Text(
heading,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(GROUP_INSET_LARGE),
)
}
return
}
Column(
Modifier.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
val barHeight = groupBarHeight()
Row(
Modifier.fillMaxWidth().height(barHeight).clickable(onClick = onToggle),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
heading,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(horizontal = GROUP_INSET_LARGE),
)
}
Column(
Modifier.padding(horizontal = GROUP_INSET),
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
) {
group.calls.forEachIndexed { index, call ->
ToolCard(
tool = call,
expanded = isToolExpanded(call.id),
onToggle = { onToolToggle(call.id) },
onAnswer = onAnswer,
image = image,
shape = connectedShape(index, group.calls.size),
)
}
}
// Shutting it from here anchors the other end: the reader is at the bottom of a long group,
// and what they are looking at is what follows it.
CollapseBar(barHeight, onToggle)
}
}
/**
* The height of a group's heading, and so of the bar at its foot.
*
* Derived from the type the heading is set in rather than written down, because the two have to
* match and a pair of numbers chosen to look equal stops being equal the moment the density
* changes.
*/
@Composable
private fun groupBarHeight(): Dp {
val line = MaterialTheme.typography.titleSmall.lineHeight
return with(LocalDensity.current) { line.toDp() } + GROUP_INSET_LARGE * 2
}
/**
* The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height
* rather than padded to something that looks close, so the surface the calls sit on is the same
* thickness at both ends.
*/
@Composable
private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
val colour = MaterialTheme.colorScheme.onSurfaceVariant
Row(
Modifier.fillMaxWidth().height(height).clickable(onClick = onToggle).semantics {
contentDescription = "Collapse these tool calls"
},
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Chevron(Pointing.Up, colour = colour)
}
}
/**
* The shape of one card in a stack of [count]: square where it faces a neighbour, rounded where it
* does not.
*
* Written once and given an index rather than branched at each end, because a stack has three cases
* that are one rule -- and the middle one is what a hand-written first/last pair gets wrong.
*/
@Composable
private fun connectedShape(index: Int, count: Int): CornerBasedShape {
val shape = MaterialTheme.shapes.medium
val square = CornerSize(0.dp)
return shape.copy(
topStart = if (index == 0) shape.topStart else square,
topEnd = if (index == 0) shape.topEnd else square,
bottomStart = if (index == count - 1) shape.bottomStart else square,
bottomEnd = if (index == count - 1) shape.bottomEnd else square,
)
}
/** The padding inside a card, and so the height a bar of one line of text comes to. */
private val GROUP_INSET_LARGE = 12.dp
/** How far the stack of calls is held off the edge of the surface it sits on. */
private val GROUP_INSET = 4.dp
/** Enough to read the join as a join rather than as one tall card. */
private val GROUP_GAP = 2.dp
/**
* One tool call.
*
* Closed, it is a single line: the tool's name and what the call is for. The command itself is not
* on it, because a wrapped command turns one row into four and a run of them into a wall.
*
* Open, it shows the command, whatever else the input carried, and the output. The timeout sits at
* the top right: it is a limit on the call rather than part of what the call does.
*
* A call waiting on permission is shown open whatever the reader last chose, since the command is
* the thing being decided and a row saying only "Bash" cannot be decided on.
*/
@Composable
fun ToolCard(
tool: TranscriptItem.ToolRun,
expanded: Boolean,
onToggle: () -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape,
) {
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
val deciding = tool.asks.any { it.answers.isEmpty() }
val open = expanded || deciding
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
Column(Modifier.padding(GROUP_INSET_LARGE)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(tool.tool, style = MaterialTheme.typography.titleSmall)
if (open) {
Spacer(Modifier.weight(1f))
parsed.timeout?.let {
Text(
"timeout $it",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
parsed.title?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
} ?: Spacer(Modifier.weight(1f))
}
// A spinner says the machine is working. While this call is waiting on an answer
// the machine is doing nothing at all -- the turn is stopped on the person reading
// it -- so it says whose move it is instead.
if (deciding) {
Spacer(Modifier.width(8.dp))
Text(
"your turn",
style = MaterialTheme.typography.labelLarge,
color = awaitingColor,
)
} else if (!tool.done) {
Spacer(Modifier.width(8.dp))
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
}
}
if (open) {
parsed.description?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
// Everything AskUserQuestion carries is the questions, and those are drawn below as
// something answerable; dumping the same JSON above them would be the decision
// stated twice, once unreadably.
if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
}
if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall)
// What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is
// prose, and a proportional font silently destroys the alignment that carried
// the meaning.
//
// Its terminal styling applied and the rest of the escapes taken out: colour is
// often the whole of what a diff or a test run is saying. Remembered against
// the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() }
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
RawBlock(Modifier.padding(top = 2.dp)) {
Text(
styled,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
}
}
// Shown open or closed. A call that produced a picture is one whose result *is* the
// picture, and a row that hides it says less than the one line it replaced.
tool.images.forEach { ref -> image(ref) }
if (tool.asks.isNotEmpty()) {
if (tool.tool == ASK_USER_QUESTION) {
AskUserQuestionBody(tool.asks, onAnswer)
} else {
tool.asks.forEach { ask -> PermissionAsk(ask, onAnswer) }
}
}
}
}
}
/**
* The permission ask on the call it is about.
*
* Only the question, not the prompt's second half: the backend sends the tool's input with it so
* the ask can stand alone, and here it does not have to -- the card above is showing exactly that.
*/
@Composable
private fun PermissionAsk(
ask: TranscriptItem.QuestionCard,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) {
// What was pressed, before the answer has been round-tripped. Two bare words with no submit
// step -- unlike a question card, where the answer is worth reviewing -- so the press has to be
// its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request
// settles: by then either the answer is in `ask.answers`, or it failed and the buttons come
// back.
var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
Spacer(Modifier.height(8.dp))
Text(
ask.prompt.substringBefore('\n'),
style = MaterialTheme.typography.bodyMedium,
color = awaitingColor,
)
// Answered or not, the options stay and the one that was taken is marked -- see
// [AskedQuestion], which is the same rule on the question card. A permission is where it
// matters most: "Answered: Deny" alone does not say that Allow was the alternative.
val settled = ask.answers.isNotEmpty()
AnswerOptions(
ask.options,
if (settled) ask.answers else listOfNotNull(pressed),
onPick =
if (settled || pressed != null) null
else
{ label ->
pressed = label
onAnswer(listOf(QuestionAnswer(ask.id, listOf(label)))) { pressed = null }
},
)
}
/**
* The tool whose input is a question rather than a command; see [AskUserQuestionBody].
*
* Also what [runIdFor] breaks a run of calls on, so the row a reader answered is never folded
* inside a collapsed group.
*/
const val ASK_USER_QUESTION = "AskUserQuestion"
@@ -1,27 +0,0 @@
package com.example.aiapp
/**
* Where one transcript lives: a session's own, or one of its subagents'.
*
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
*/
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
val urlPath: String
get() =
if (subagentId == null) "sessions/$sessionId"
else "sessions/$sessionId/subagents/$subagentId"
/**
* Where this transcript's cache lives on the phone, relative to the cache root.
*
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
* session's cache directory takes its subagents' with it -- the same one-way door the server's
* own storage describes.
*/
val cachePath: String
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
}
@@ -1,596 +0,0 @@
package com.example.aiapp
import android.util.Log
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.io.RandomAccessFile
/**
* This phone's copy of the transcripts it has already been sent, so reopening a session does not
* download it again.
*
* What is stored is the server's own JSON for one event per line, in transcript order. Reading the
* cache means running the same [parseSeqEvent] the network path runs, so a cached transcript and a
* fetched one cannot draw differently, and an event type this build does not know keeps every field
* it arrived with for the build that will. Rows are deliberately *not* what is stored: a row is a
* rendering, and a cache of rows would need throwing away on every update that touched `foldEvent`.
*
* See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it:
* 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the
* cache is a copy and is never inferred, folded or edited here;
* 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache;
* 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a
* cold open, never to a blank or a wrong screen; 4. a line already on the phone is not fetched
* again.
*
* A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs
* under the JVM unit tests. That is also why there is no JSON parser here: what it needs off a line
* is the sequence number and whether the line is a streamed delta, both read with a regex. A line
* it cannot read that way is treated as damage. [warn] is where failures are said for the same
* reason.
*/
class TranscriptCache(
private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/**
* The cache for one transcript, whether or not anything has been stored for it yet.
*
* A subagent's [TranscriptAddress.cachePath] nests it under its session's directory, so
* deleting the session (below) takes its subagents' caches with it -- there is no separate
* purge for one.
*/
fun session(address: TranscriptAddress): SessionCache =
SessionCache(File(root, address.cachePath), warn)
/**
* Deletes every session directory not in [ids], called after a successful list fetch. The path
* out for a session deleted on another device: nothing here would otherwise hear about it, and
* unlike a draft's few bytes what it leaves behind is megabytes.
*/
fun retainOnly(ids: Set<String>) =
guardIo(Unit, warn) {
sessionDirs().forEach { if (it.name !in ids) it.deleteRecursively() }
}
/**
* Deletes least-recently-touched session directories, never [keep], until the whole of this
* server's cache is under [budget]. Least-recently-touched rather than largest: what a reader
* is likely to open again is what they opened last, and evicting the big ones first would empty
* the cache for exactly the conversations it exists for.
*/
fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) =
guardIo(Unit, warn) {
val dirs = sessionDirs().sortedBy { it.lastModified() }
var total = dirs.sumOf { sizeOf(it) }
for (dir in dirs) {
if (total <= budget) break
if (dir.name == keep) continue
val was = sizeOf(dir)
if (dir.deleteRecursively()) total -= was
}
}
fun purgeAll() = guardIo(Unit, warn) { root.deleteRecursively() }
private fun sessionDirs(): List<File> = root.listFiles()?.filter { it.isDirectory }.orEmpty()
}
/**
* How much of this phone's cache directory all of one server's transcripts may take. A dozen of the
* largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small fraction of a phone.
* A number to revisit against real use rather than a measurement of anything.
*/
const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000
/**
* What the newest cached line says, which is what the probe checks against the server. Both halves
* are wanted together: the seq is what the request asks about, and the line is what its answer is
* compared with.
*/
data class CachedTail(val seq: Long, val line: String)
/**
* One session's cached lines, as a directory of chunks.
*
* A chunk is a set of lines *and a claim about what they cover*, and the two are not the same
* thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the
* run's oldest delta, so a page whose newest event is seq 1,200 may cover everything up to the
* 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open range
* in the file's name:
* ```
* <first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
* <first>-<end>.raw.jsonl an uncoalesced page, or a closed live run <first>-open.raw.jsonl the
* live run; end is its last line's seq + 1
* ```
*
* Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run ending
* at the newest chunk -- the **suffix** -- is ever served: chunks behind a gap are kept, because
* the gap is usually closed by paging back through it, but nothing is served across one.
*
* **The newest chunk is always raw**, which is what makes the stream cursor and the probe well
* defined. It holds by construction (the opening window and every stream frame are raw) and is
* checked on read: a `.rows` chunk at the newest end can only mean this app died between closing
* one live run and opening the next, and it discards the session.
*
* Nothing here is load-bearing. Every operation that touches the disk answers as though the cache
* were empty when it cannot, and a write failure disables writing for the rest of this instance's
* life so that a full disk costs one log line rather than one per delta.
*
* Every operation is synchronized, because two of them really do run at once: the stream appends
* live events from its own IO thread while a reader scrolling back reads pages from another. What
* it buys is that the open chunk's name, its end and its writer are never read half-rotated.
*/
class SessionCache(
private val dir: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/** Set by the first write that fails: a second would fail the same way, once per delta. */
private var disabled = false
/**
* The open chunk's writer, its file, and the seq that chunk now ends at.
*
* Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of
* times a second while a reply streams. What that costs is the unflushed tail on a crash, which
* is safe: a shorter cache is a longer catch-up, never a wrong one.
*/
private var writer: BufferedWriter? = null
private var openFile: File? = null
private var openEnd: Long = 0
/**
* The newest line of the suffix, or null when there is none or the newest chunk is not raw.
*
* This is the cursor the live stream would resume from, so it is also what has to be shown to
* still be the server's own line before anything is resumed from it.
*/
@Synchronized
fun tail(): CachedTail? =
guard(null) {
val newest = suffix().lastOrNull() ?: return@guard null
var found: CachedTail? = null
eachLine(newest) { line ->
found = CachedTail(seqOf(line)!!, line)
false
}
found
}
/** The newest [limit] lines of the suffix, oldest first -- the opening window. */
@Synchronized
fun newest(limit: Int): List<String> =
guard(emptyList()) {
val taken = ArrayDeque<String>()
for (chunk in suffix().asReversed()) {
if (taken.size >= limit) break
eachLine(chunk) { line ->
taken.addFirst(line)
taken.size < limit
}
}
taken.toList()
}
/**
* The page of lines before [before], oldest first, or null when the cache cannot answer.
*
* Null is a miss -- the suffix does not cover the ground immediately below [before] -- and
* means the server has to be asked. Deliberately not an empty list: an empty page is how the
* screen is told it has reached the start of the conversation, and a cache saying that of
* history it merely does not hold would stop the transcript scrolling back for good.
*
* [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open
* leaves behind is in the middle of the live run, so a cache that could only answer at a
* boundary would send the very first backwards page to the server and, since that page would
* overlap the run, keep none of it.
*
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`.
* The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of
* its first delta either way.
*/
@Synchronized
fun page(before: Long, limit: Int, rows: Boolean): List<String>? =
guard(null) {
val suffix = suffix()
val newest = suffix.lastOrNull() ?: return@guard null
// Above what is held, or at or below where it starts: either way the run the caller is
// scrolling into is not continuous with this one, and only the server has it.
if (before > newest.end || before <= suffix.first().first) return@guard null
val taken = ArrayDeque<String>()
var counted = 0
var inRun = false
var wanting = true
for (chunk in suffix.asReversed()) {
if (!wanting) break
if (chunk.first >= before) continue
eachLine(chunk) { line ->
// The page is what is *before* the cursor; the rows at or above it are already
// on screen.
if (seqOf(line)!! >= before) return@eachLine true
if (rows) {
val delta = isDelta(line)
// Stop only between rows: a delta continuing the run being gathered is part
// of a row already counted, and breaking on it would drop the half of that
// row already taken.
if (counted >= limit && !(delta && inRun)) wanting = false
else {
if (!delta || !inRun) counted++
inRun = delta
}
} else if (taken.size >= limit) {
wanting = false
}
if (wanting) taken.addFirst(line)
wanting
}
}
taken.toList()
}
/**
* The `end` of the nearest chunk at or below [before], which is the floor a fetched page is
* asked with so that it stops where this phone's copy starts. Null when there is no such chunk.
*
* Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that
* the gap is closed with exactly the bytes it is wide.
*/
@Synchronized
fun coveredUpTo(before: Long): Long? =
guard(null) { chunks().map { it.end }.filter { it <= before }.maxOrNull() }
/**
* Stores a fetched page covering `[first, end)`; false when it was not stored.
*
* Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced
* event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from
* arising by bounding what it fetches, and this is the guard for a page that arrives anyway.
* Such a page is still drawn; it is only not kept.
*
* The newest chunk is never stored through here: the opening window and every live frame go
* through [append], which is what keeps the newest chunk raw and open.
*/
@Synchronized
fun storePage(lines: List<String>, first: Long, end: Long, rows: Boolean): Boolean =
guard(false) {
if (disabled || lines.isEmpty() || end <= first) return@guard false
if (chunks().any { first < it.end && it.first < end }) return@guard false
dir.mkdirs()
val kind = if (rows) "rows" else "raw"
File(dir, "$first-$end.$kind.jsonl").writeText(lines.joinToString("\n", postfix = "\n"))
true
}
/**
* Appends one live event, which is also how a freshly fetched opening window is stored.
*
* A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a
* `reset` looks like from here -- and closes the open chunk under the end it turned out to
* have. A smaller one is already covered and is ignored; the SSE contract is `seq > after`.
*/
@Synchronized
fun append(line: String, seq: Long) =
guard(Unit) {
if (disabled) return@guard
val writer = writerFor(seq) ?: return@guard
// Written as it arrived. A newline inside it would split one event into two unreadable
// halves, but neither source can produce one: SSE framing forbids it, and a page's
// elements are re-serialized compactly, which escapes it.
writer.write(line)
writer.write("\n")
openEnd = seq + 1
}
/**
* Flushes what [append] has buffered. Called on each `Status` event -- the boundaries of a
* turn, which is the granularity a crash may as well lose -- and when the stream closes.
*/
@Synchronized fun flush() = guard(Unit) { writer?.flush() }
/** What [purge] would discard, for the reload row in session settings. */
@Synchronized fun bytes(): Long = guard(0L) { sizeOf(dir) }
/** Marks this session as visited, which is what eviction ranks by. */
@Synchronized
fun touch() =
guard(Unit) { if (dir.isDirectory) dir.setLastModified(System.currentTimeMillis()) }
@Synchronized
fun purge() =
guard(Unit) {
closeWriter()
dir.deleteRecursively()
}
// -- chunks ------------------------------------------------------------------------------
private data class Chunk(val file: File, val first: Long, val end: Long, val open: Boolean) {
val rows: Boolean
get() = file.name.endsWith(".rows.jsonl")
}
/**
* Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored.
* Recomputed per operation rather than kept: another operation may have changed the directory.
*/
private fun chunks(): List<Chunk> {
writer?.flush()
return dir.listFiles()
.orEmpty()
.mapNotNull { file ->
val match = CHUNK_NAME.matchEntire(file.name) ?: return@mapNotNull null
val first = match.groupValues[1].toLongOrNull() ?: return@mapNotNull null
val open = match.groupValues[2] == "open"
val end = if (open) openEndOf(file, first) else match.groupValues[2].toLongOrNull()
// A chunk covering nothing is one that was created and never written to -- an
// append whose very first write failed. It says nothing, so it is not a chunk.
if (end == null || end <= first) null else Chunk(file, first, end, open)
}
.sortedBy { it.first }
}
/**
* The open chunk's end: its last line's seq plus one, or the in-memory end while this instance
* is the one writing it.
*
* An open chunk whose last line cannot be read is this app having died mid-write. That line is
* dropped and the file truncated to the last good one, which is the one place damage is
* repaired rather than discarded: the tail of an append-only file is the only place a partial
* line can be.
*/
private fun openEndOf(file: File, first: Long): Long {
if (openFile == file && openEnd > 0) return openEnd
repairTail(file)
var end = first
eachLineBackwards(file) { _, line ->
seqOf(line)?.let { end = it + 1 }
false
}
return end
}
/**
* The contiguous run of adjacent chunks ending at the newest one, oldest first.
*
* A newest chunk that is not raw cannot happen while this code is the only writer, and means
* the directory is not to be trusted -- so the session is discarded.
*/
private fun suffix(): List<Chunk> {
val all = chunks()
var index = all.size - 1
val newest = all.lastOrNull() ?: return emptyList()
if (newest.rows) throw Damaged(newest.file)
val run = ArrayDeque<Chunk>()
run.addFirst(newest)
while (index > 0 && all[index - 1].end == run.first().first) {
index--
run.addFirst(all[index])
}
return run.toList()
}
/**
* Each line of [chunk], newest first, until [take] says stop.
*
* Backwards and lazily, because every question this cache is asked is about the newest end and
* a live run grows to the size of the conversation. Reading the file whole to answer with
* eighty lines of it is the cost the server's own reader was rewritten to stop paying.
*
* Damage anywhere but at the tail of the open chunk was not written by this code, and there is
* no honest way to say what a chunk covers with a line of it unreadable -- so it discards the
* session rather than serving what it can read.
*/
private fun eachLine(chunk: Chunk, take: (String) -> Boolean) {
eachLineBackwards(chunk.file) { _, line ->
if (seqOf(line) == null) throw Damaged(chunk.file)
take(line)
}
}
// -- writing -----------------------------------------------------------------------------
/** The writer for the chunk [seq] belongs in, opening or rotating one as it has to. */
private fun writerFor(seq: Long): BufferedWriter? {
writer?.let { held ->
if (seq == openEnd) return held
if (seq < openEnd) return null
// A gap: what this instance has written covers up to `openEnd`, and that is the name
// the chunk gets before a new one starts at the arriving seq.
closeOpenChunk(openEnd)
}
dir.mkdirs()
// An open chunk left by an earlier instance, or by an earlier screen.
chunks()
.lastOrNull { it.open }
?.let { existing ->
if (seq < existing.end) return null
if (seq == existing.end) {
openFile = existing.file
openEnd = existing.end
return FileWriter(existing.file, true).buffered().also { writer = it }
}
rename(existing.file, existing.first, existing.end)
}
// A chunk that was created and never written to would otherwise be left behind under a name
// a second one is about to want; it covers nothing, so nothing is lost with it.
dir.listFiles().orEmpty().forEach {
if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L)
it.delete()
}
val file = File(dir, "$seq-open.raw.jsonl")
openFile = file
openEnd = seq
return FileWriter(file, false).buffered().also { writer = it }
}
/** Renames the open chunk to the range it turned out to cover, so it stops being open. */
private fun closeOpenChunk(end: Long) {
val file = openFile
closeWriter()
if (file == null) return
val first = CHUNK_NAME.matchEntire(file.name)?.groupValues?.get(1)?.toLongOrNull()
if (first != null) rename(file, first, end)
}
private fun rename(file: File, first: Long, end: Long) {
file.renameTo(File(dir, "$first-$end.raw.jsonl"))
}
private fun closeWriter() {
try {
writer?.close()
} catch (_: IOException) {
// Nothing left to do about it: the file is what it is, and the read path repairs a
// half-written tail.
}
writer = null
openFile = null
openEnd = 0
}
// -- failure -----------------------------------------------------------------------------
/** A chunk that cannot be read as what its name claims. */
private class Damaged(val file: File) : RuntimeException()
/**
* Runs [body], answering [ifBroken] when the directory cannot give a real answer.
*
* None of this is reported on screen: none of it changes what the screen shows -- every read
* here has a network path beside it producing the same result -- and the reader has nothing to
* do about it. Damage discards this session's cache, which makes the next open an ordinary cold
* one.
*/
private fun <T> guard(ifBroken: T, body: () -> T): T =
// A disk that refused once will refuse again, once per delta, so the first refusal is also
// the last: this instance stops writing rather than logging a line a token.
guardIo(
ifBroken,
warn,
onFailure = {
disabled = true
closeWriter()
},
) {
try {
body()
} catch (e: Damaged) {
warn("transcript cache damaged at ${e.file}; discarding ${dir.name}")
closeWriter()
dir.deleteRecursively()
ifBroken
}
}
}
/** `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is not ours. */
private val CHUNK_NAME = Regex("""^(\d+)-(\d+|open)\.(rows|raw)\.jsonl$""")
private val SEQ_IN_LINE = Regex(""""seq"\s*:\s*(\d+)""")
private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""")
/**
* One line's sequence number, or null when the line is not one of ours.
*
* A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM
* tests: the seq is the first field the server writes, so the first match is the top-level one.
*/
private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull()
/** Whether a line is one streamed piece of a reply, which is what makes a run of them one row. */
private fun isDelta(line: String): Boolean =
TYPE_IN_LINE.find(line)?.groupValues?.get(1) == "assistantText"
/**
* How much of a file is read at a time when walking it backwards. One block covers a page of a
* transcript comfortably, and the walk stops as soon as the caller has what it asked for.
*/
private const val READ_BLOCK = 64 * 1024
/**
* Calls [onLine] with each non-blank line of [file], **newest first**, along with the byte offset
* it starts at, until [onLine] answers false.
*
* Every question the cache is asked is about the newest end of a chunk, and a live run reaches the
* size of the conversation, so reading forwards means reading a transcript to answer with the last
* eighty lines of it.
*
* Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte
* UTF-8 sequence; each line is decoded whole. A missing file yields nothing.
*/
private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) {
if (!file.isFile) return
RandomAccessFile(file, "r").use { handle ->
// Bytes below `unread` have not been looked at; `pending` is the oldest line so far, which
// is incomplete until a newline is found before it in an older block.
var unread = handle.length()
var pending = ByteArray(0)
while (unread > 0) {
val take = minOf(READ_BLOCK.toLong(), unread).toInt()
val start = unread - take
val block = ByteArray(take)
handle.seek(start)
handle.readFully(block)
val buffer = if (pending.isEmpty()) block else block + pending
var lineEnd = buffer.size
var at = buffer.size - 1
while (at >= 0) {
if (buffer[at] == NEWLINE) {
val line = String(buffer, at + 1, lineEnd - at - 1, Charsets.UTF_8)
if (line.isNotBlank() && !onLine(start + at + 1, line)) return
lineEnd = at
}
at--
}
pending = buffer.copyOfRange(0, lineEnd)
unread = start
}
// The first line of a file has no newline before it to be found.
val first = String(pending, Charsets.UTF_8)
if (first.isNotBlank()) onLine(0, first)
}
}
private const val NEWLINE = '\n'.code.toByte()
/**
* Drops a final line that is not one of ours, by truncating the file to where it starts.
*
* This app having died mid-write is the one kind of damage that is repaired rather than discarded:
* the tail of an append-only file is the only place a partial line can be. A second bad line is not
* this, and is left for the read path to notice.
*/
private fun repairTail(file: File) {
var truncateTo = -1L
eachLineBackwards(file) { offset, line ->
if (seqOf(line) == null) truncateTo = offset
false
}
if (truncateTo >= 0) RandomAccessFile(file, "rw").use { it.setLength(truncateTo) }
}
private fun sizeOf(file: File): Long =
if (file.isDirectory) file.listFiles().orEmpty().sumOf { sizeOf(it) } else file.length()
/**
* The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance.
* [onFailure] is what the caller does about it beyond answering [ifBroken].
*/
private fun <T> guardIo(
ifBroken: T,
warn: (String) -> Unit,
onFailure: () -> Unit = {},
body: () -> T,
): T =
try {
body()
} catch (e: IOException) {
warn("transcript cache unusable: ${e.message}")
onFailure()
ifBroken
} catch (e: SecurityException) {
warn("transcript cache unreadable: ${e.message}")
onFailure()
ifBroken
}
@@ -1,547 +0,0 @@
package com.example.aiapp
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]).
*
* Events are the only data source, and there is deliberately no second shape for history to drift
* from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are
* all the same events through the same parser. [TranscriptCache] stores the server's lines rather
* than these rows for exactly that reason -- a row is a rendering, and its shape changes whenever
* this file does.
*/
@Immutable
sealed class TranscriptItem {
/**
* The transcript sequence number this row started at, and its identity on screen.
*
* The list is drawn newest-first, so every new message is an insertion at index 0 and every
* page of history is an insertion at the far end. Without an identity that survives both, the
* list is addressed by position: whatever somebody had scrolled to keeps its index while the
* content underneath it slides, which reads as the view scrolling on its own.
*
* A row built from several events keeps the seq of the first, so it holds still while the rest
* of it arrives.
*/
abstract val seq: Long
/**
* This item's identity on screen, which is its [seq] for everything that has one of its own.
*
* Here rather than in [TranscriptRow.Single] because the two items that need something else are
* the two that know why. Asking each item what it is called is also what stops the next such
* item being missed -- a `when` over concrete types would have to gain a case, silently.
*/
open val key: Any
get() = seq
data class UserMsg(
override val seq: Long,
val text: String,
/** Refs of what was attached, drawn inside the bubble. */
val attachments: List<String> = emptyList(),
) : TranscriptItem()
data class AssistantMsg(
override val seq: Long,
val text: String,
/**
* Whether this reply is finished: the session has stopped working since its last delta.
*
* What it buys is the split. [transcriptUnits] keeps the newest reply whole because a
* streaming reply's text changes per delta and splitting a changing text is a parse per
* delta -- but "newest" outlives the turn, so a session that ends on a long reply was
* drawing it as one item indefinitely. Measured on a Pixel 9 Pro XL: one 34,996px reply on
* screen put the frame's draw phase at 13.8ms, 79% of it framework bookkeeping.
*
* Folded from the status event that ended the turn rather than read off the screen's
* status, because rows only change through the held-events gate: the split changes the
* newest row's list identity, and doing that from a status flip while somebody is reading
* inside that reply would step the list under them.
*/
val settled: Boolean = false,
) : TranscriptItem()
data class ToolRun(
override val seq: Long,
val id: String,
/**
* The run of adjacent calls this one belongs to, named once when the call is folded in and
* never recomputed.
*
* Carried rather than derived because a run can gain members at *either* end, so no
* function of its current members is stable. It is the first call's id at the moment the
* run started, which is a name rather than a description: [joinPages] hands it to older
* calls that turn out to belong to the same run.
*/
val runId: String,
val tool: String,
val input: String,
val output: String,
val done: Boolean,
/**
* The questions this call is waiting on, in the order they were asked.
*
* On the call's own row rather than beside it: an ask used to arrive as a second card
* repeating the input verbatim, so the reader saw the same command twice. The backend says
* which call a question is about, so this is a fact rather than a match on the input.
*
* A list because AskUserQuestion asks up to four at once, and a permission is the case of
* exactly one rather than a different shape.
*/
val asks: List<QuestionCard> = emptyList(),
/**
* Images this call's result carried, drawn under it. Beside it they had to be paired by
* position, and position is what a page boundary breaks.
*/
val images: List<String> = emptyList(),
) : TranscriptItem() {
/** A run, not a seq: see [TranscriptRow.key] for what that identity has to survive. */
override val key: Any
get() = runId
}
data class QuestionCard(
override val seq: Long,
val id: String,
val prompt: String,
/** A few words naming what this is about, when the asker offered one. */
val header: String?,
val options: List<QuestionOption>,
/** Whether several options may be chosen at once. */
val multiSelect: Boolean,
/** What was chosen, once something was; empty until then. */
val answers: List<String>,
) : TranscriptItem()
data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem()
/** An image by server-side ref, fetched from the session's files route. */
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
/**
* A message another agent sent this session. Its own row rather than a [UserMsg]: see
* [PeerMessageRow] for why the voice matters.
*/
data class PeerNote(
override val seq: Long,
val from: String,
val text: String,
/**
* The seq of the event this note came in on, which is what makes it itself.
*
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began
* at. Two messages that arrive during one turn therefore share a seq -- and sharing an
* identity as well killed the app, because the list refuses two items with one key.
*/
val arrived: Long = seq,
) : TranscriptItem() {
override val key: Any
get() = arrived
}
/**
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
* than only shown while it waits, because it explains what follows: a conversation that
* suddenly has half the context, or a session with a new name.
*/
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
/** Placeholder row for events this build can't render (newer kinds). */
data class Note(override val seq: Long, val text: String) : TranscriptItem()
/**
* A clear that happened: everything above it left the session's context and stayed on screen.
* Carries only its position, because that is all it means.
*/
data class ClearedNote(override val seq: Long) : TranscriptItem()
/**
* A compaction that happened, and what it recovered.
*
* In the transcript rather than only in the status line, because the status is gone the moment
* it finishes and this is the part worth keeping: the explanation for a gap in the
* conversation.
*
* The wire also says what triggered it, and this deliberately does not carry that -- the row
* says the two sizes and nothing else, so keeping the trigger would be a field nothing can
* read.
*/
data class CompactedNote(
override val seq: Long,
val preTokens: Long?,
val postTokens: Long?,
) : TranscriptItem()
/**
* The account ran out of quota, so the turn stopped here.
*
* A divider rather than an error: nothing failed, and what a reader scrolling back needs from
* it is the same thing a clear or a compaction gives them -- why the conversation stops at this
* line.
*
* [resetsAt] is epoch seconds and null where the session was told nothing, which is a state the
* row has words for rather than a time it invents.
*/
data class LimitNote(override val seq: Long, val resetsAt: Double?) : TranscriptItem()
}
/**
* The run a call joins: the one it lands next to, or a new one named after itself.
*
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
* keeps whatever it was called when it started, however many calls arrive at either end afterwards.
*
* A question to the reader is in a run of its own, which is what puts it on the transcript as a row
* rather than inside a collapsed "Called 6 tools" card. Two things follow: it is always visible,
* since a run of one is drawn as itself; and the calls around it fall into a group before it and a
* group after it, so where the reader was asked something is legible in the shape of the transcript
* without opening anything.
*/
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
if (tool == ASK_USER_QUESTION || previous.tool == ASK_USER_QUESTION) return id
return previous.runId
}
/**
* Puts a page of older items in front of the ones already loaded, healing whatever the page
* boundary cut in two.
*
* Two things straddle a boundary: a tool call separated from its result, and a message separated
* from the rest of itself. Both were one thing before the transcript was cut into pages.
*
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and
* concatenating the two lists left *both*: the same call twice.
*
* Merged by the call's own id rather than by position, because position is exactly what a page
* boundary destroys. The older row wins on what a start knows and the newer on what an end knows,
* which is the only way round that loses nothing.
*
* The third thing is the *run*, and it is the one that used to be missed. Every page ends up here,
* but [adoptRun] only ran on the path where a split call had been found -- so the boundary that
* falls cleanly between two finished calls, which is most of them, left the older page's calls
* under the run name they were folded with. On screen: one run of tool calls drawn as two groups,
* with the seam wherever the reader happened to have paged.
*/
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitMessage(earlier, later)
val startedEarlier =
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
val endedLater =
newer
.filterIsInstance<TranscriptItem.ToolRun>()
.associateBy { it.id }
.filterKeys { it in startedEarlier }
val healed = older.map { row ->
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
if (row is TranscriptItem.ToolRun && half != null) {
row.copy(
output = half.output,
done = half.done,
// Kept from both halves: a question or an image can be attached to either,
// depending on which side of the boundary its event fell.
asks = row.asks + half.asks,
images = row.images + half.images,
)
} else {
row
}
}
val kept = newer.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
return adoptRun(healed, kept) + kept
}
/**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
*
* [foldEvent] never leaves two assistant messages next to each other inside one page, so two
* meeting at a join are always the two halves of one reply, and leaving them apart drew a single
* answer as two with a paragraph break through the middle of a sentence.
*
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
* loaded, so the growth extends off the top of the screen.
*/
private fun healSplitMessage(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
val head = earlier.lastOrNull()
val tail = later.firstOrNull()
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
return earlier to later
}
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
}
/**
* Hands the older calls at the join the name of the run they are joining.
*
* The two pages were folded separately, so a run split by the boundary came back as two runs with
* two names. Naming the joined run after the *older* half would be the obvious way round and is
* wrong: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor.
*/
private fun adoptRun(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): List<TranscriptItem> {
val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier
// A question is in a run of its own on both sides of the join, the same as it would be had the
// two pages been folded as one. Without this the heal would merge a group straight through the
// row the reader was asked something on.
if (first.tool == ASK_USER_QUESTION) return earlier
val joining = first.runId
val tail = earlier.takeLastWhile {
it is TranscriptItem.ToolRun && it.tool != ASK_USER_QUESTION
}
if (tail.isEmpty()) return earlier
return earlier.dropLast(tail.size) +
tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) }
}
/**
* A peer message goes above the turn it started, not where it happened to arrive.
*
* The live Claude Code path cannot record it in place: the CLI says nothing about a peer message
* until the turn's `result`, so the event lands below the whole reply it caused. The server stamps
* it with where that turn began and the note takes that seq.
*
* Taking the turn's opening seq as its own is also what keeps the list sorted, which anchors and
* paging both depend on. It is only a *position*, though, and the note keeps its own arrival seq as
* its identity ([TranscriptItem.PeerNote.arrived]). The argument for sharing was that the turn's
* seq belongs to a status change and a status draws no row -- true, and it answered the wrong
* question: what two notes stamped with the same turn collide with is each other.
*
* Without a stamp -- a message replayed out of a session file -- it stays where it arrived.
*/
private fun placePeerNote(
items: List<TranscriptItem>,
seq: Long,
event: SessionEvent.PeerMessage,
): List<TranscriptItem> {
val at = event.turnStart ?: return items + TranscriptItem.PeerNote(seq, event.from, event.text)
val note = TranscriptItem.PeerNote(at, event.from, event.text, arrived = seq)
val index = items.indexOfFirst { it.seq > at }
if (index < 0) return items + note
val behind = (items.getOrNull(index - 1) as? TranscriptItem.ToolRun)?.runId
return items.subList(0, index) + note + splitRun(items.subList(index, items.size), behind)
}
/**
* The calls the note now sits in front of, renamed if they were sharing a run with the calls behind
* it.
*
* A run is named from what a call landed next to, and nothing there knows about turns -- so a turn
* opening with a tool call, straight after one that ended with one, folds them into a single run.
* Left alone, [groupToolRuns] would flush at the note and hand both halves the same name: two rows
* with one key, which a keyed list cannot draw at all.
*
* The later half is the one renamed, which is the opposite of a page join ([adoptRun]) and right
* for the opposite reason: there the two halves were always one run, here they were never one
* turn's work.
*/
private fun splitRun(tail: List<TranscriptItem>, behind: String?): List<TranscriptItem> {
val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail
if (behind == null || first.runId != behind) return tail
val run = tail.takeWhile { it is TranscriptItem.ToolRun && it.runId == behind }
return run.map { (it as TranscriptItem.ToolRun).copy(runId = first.id) } + tail.drop(run.size)
}
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
when (val event = entry.event) {
is SessionEvent.UserMessage ->
items + TranscriptItem.UserMsg(entry.seq, event.text, event.attachments)
is SessionEvent.AssistantText -> {
// Deltas accumulate into the message they're streaming, which keeps the seq of the
// first of them: a row whose identity changed with every delta would be a new row on
// every frame, and the list would jump for the whole of a streamed answer.
val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg) {
// A message growing again is not finished, whatever a status said in between.
items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false)
} else {
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
}
}
is SessionEvent.ToolStart ->
items +
TranscriptItem.ToolRun(
entry.seq,
event.id,
runIdFor(items, event.id, event.tool),
event.tool,
event.input,
"",
done = false,
)
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd ->
// Created when its start is not here, rather than dropped. A fold that only ever
// *updates* loses the whole call when the start fell outside the loaded window, and a
// tool call that renders as nothing is indistinguishable from one that never happened.
// Loading the page before this one replaces the row with the real thing.
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
} else {
items +
TranscriptItem.ToolRun(
entry.seq,
event.id,
// The name is not known from an end alone, so a call that was an ask cannot
// be recognised as one here; the page before this replaces the row.
runIdFor(items, event.id, "tool"),
"tool",
"",
event.output,
done = true,
)
}
is SessionEvent.Question -> {
val card =
TranscriptItem.QuestionCard(
entry.seq,
event.id,
event.prompt,
event.header,
event.options,
event.multiSelect,
emptyList(),
)
// A question with no tool behind it -- AskUserQuestion, or an ask whose call fell
// outside the loaded window -- is a card of its own.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
) {
updateTool(items, event.about) { it.copy(asks = it.asks + card) }
} else {
items + card
}
}
is SessionEvent.Answered ->
// Resolved wherever it is drawn: a card of its own, or a tool row's ask. Missing the
// second left an Allow/Deny pair live on a question already answered from another
// device.
items.map {
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
it.copy(answers = event.answers)
it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } ->
it.copy(
asks =
it.asks.map { ask ->
if (ask.id == event.id) ask.copy(answers = event.answers)
else ask
}
)
else -> it
}
}
is SessionEvent.PeerMessage -> placePeerNote(items, entry.seq, event)
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.CommandQueued -> items
// No row of its own: a message that is still waiting is drawn as a pending bubble below the
// transcript, and becomes an ordinary one where the session read it.
is SessionEvent.MessageQueued -> items
// The bubble goes away and nothing takes its place: the message was never read, so there is
// nothing it belongs above.
is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items
is SessionEvent.Status -> settleReply(items, event.state)
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of its own when there is
// not -- a person's own attachment belongs to no call, and neither does one whose call
// fell outside the loaded window.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
) {
updateTool(items, event.about) { it.copy(images = it.images + event.ref) }
} else {
items + TranscriptItem.ImageItem(entry.seq, event.ref)
}
is SessionEvent.LimitReached -> items + TranscriptItem.LimitNote(entry.seq, event.resetsAt)
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.UsageDelta -> items
}
/**
* A status saying the session stopped working is the moment its newest reply is finished.
*
* See [TranscriptItem.AssistantMsg.settled]. Status changes are transcript events with seqs of
* their own, so a replayed session settles its replies the same way a live one does.
*/
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
if (sessionWorking(state)) return items
val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items
if (last.settled) return items
return items.dropLast(1) + last.copy(settled = true)
}
private fun updateTool(
items: List<TranscriptItem>,
id: String,
change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun,
): List<TranscriptItem> = items.map {
if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
}
/**
* Where markdown is parsed ahead of being drawn: two threads, never all of them.
*
* The default dispatcher sizes itself to the machine, which is right for work somebody is waiting
* on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and
* taking every core for them leaves the thread that draws the frame queueing behind one -- measured
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile.
*/
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
/**
* Parses the markdown among [rows], off whatever thread is drawing.
*
* Called where a page of transcript is folded rather than where a row is composed, which is the
* whole point: the work happens seconds before the reader reaches the rows it was done for.
*
* What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer
* message -- because a string warmed under a key no row ever looks up is a miss that nothing
* reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same
* [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages
* hand it back through here.
*
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
* message was missing: this used to filter for assistant replies alone, so the one row type nobody
* had thought about paid its whole parse in the frame it appeared in.
*/
suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
withContext(parsingThreads) {
val texts = rows.flatMap { row ->
when (row) {
is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
// A message from another agent is markdown too, and it is the longest thing in a
// transcript often enough that leaving it out was the whole of why one cost a fifth
// of a second to open.
is TranscriptItem.PeerNote -> listOf(row.text)
else -> emptyList()
}
}
if (texts.isNotEmpty()) replies.warm(texts)
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's licence
// to draw these as blocks on the composing thread.
rows.forEach { if (it is TranscriptItem.AssistantMsg) replies.markSplitReady(it.text) }
}
}
@@ -1,128 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.text.selection.SelectionState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* The transcript: a lazy list of [TranscriptUnit]s, laid out in reverse.
*
* Reverse layout is what makes the two insertions this list gets free rather than corrected. Item
* zero is the newest content and sits at the bottom, so a message arriving extends the end the
* viewport is pinned to and following it is not an effect -- and a page of older history lands at
* indices past everything visible, which moves nothing on screen. The keyboard is the same case
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge.
*
* The lazy list is also the whole of the windowing. Only what is near the viewport is composed and
* alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the
* property a plain column here had to approximate with retained ranges and stand-in spacers, each
* of which was a way to flicker.
*
* What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a
* reply, and its parse is already made by [warm] before the fold that introduces it.
*
* The whole list sits in a [SelectionContainer], which is what makes every word selectable by the
* platform's own press-and-hold. Here rather than at each place text is drawn: a transcript is one
* body of text to a reader, and a container per row would mean a selection could never cross from a
* reply into the tool output that follows it -- and would leave whatever was drawn without one
* silently unselectable. Rows keep their tap handlers: selection is a long press.
*
* [selection] is the container's own state, held by the caller rather than made here, because the
* rows have to be able to ask whether anything is selected before they act on a tap.
*/
@Composable
fun TranscriptList(
units: List<TranscriptUnit>,
state: LazyListState,
moreHistory: Boolean,
selection: SelectionState,
modifier: Modifier = Modifier,
below: @Composable () -> Unit,
unit: @Composable (TranscriptUnit) -> Unit,
) {
SelectionContainer(selection) {
LazyColumn(
state = state,
reverseLayout = true,
contentPadding = TRANSCRIPT_PADDING,
modifier =
// Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which
// half. Measure includes composing the items that scrolled in.
modifier
.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record(
"measure: the whole transcript",
System.nanoTime() - started,
)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
DebugStats.record(
"place: the whole transcript",
System.nanoTime() - placing,
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record("draw: the whole transcript", System.nanoTime() - started)
},
) {
// The bottom of the screen: what is waiting to be read sits under the newest message.
// The one item before the units, which is what [UNITS_START] counts.
item(key = "below", contentType = "below") { below() }
items(count = units.size, key = { units[it].key }, contentType = { units[it]::class }) {
val u = units[it]
DebugStats.count("unit composed")
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
}
// Standing in for everything not fetched yet. Only here while there is more -- its
// appearance at the top edge is also roughly when the next page is asked for, so what
// it reports is a fetch in flight rather than an end reached.
if (moreHistory) {
item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
CircularProgressIndicator(
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
)
}
}
}
}
}
}
/**
* How many of the list's own items come before the first unit -- the waiting-messages slot.
*
* Named once because two things count on it: the list building itself, and [visibleUnits] turning a
* list index back into the unit that was drawn there. Read off by hand at the second of those, it
* is an off-by-one that misnames every row in a report and looks like a plausible answer.
*/
const val UNITS_START = 1
/** The gap between rows, and the room around the whole conversation. */
val TRANSCRIPT_SPACING: Dp = 8.dp
val TRANSCRIPT_PADDING: PaddingValues = PaddingValues(16.dp)
/** Smaller than the whole-screen loading spinner: it stands in for a page, not for everything. */
private val HISTORY_SPINNER = 24.dp
@@ -1,177 +0,0 @@
package com.example.aiapp
import android.content.Context
import java.io.File
import java.util.concurrent.atomic.AtomicReference
/**
* Where the session screen gets a transcript from: this phone's copy first, the server for the
* rest.
*
* One seam rather than a cache the screen has to remember to consult. Everything it fetched before
* is asked of this, and everything the server sends is written into the cache on the way past, so
* the screen never learns which side answered. What it does learn, through [DebugStats], is how
* often each one did.
*
* See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind: the cache is never load-bearing.
* Every read has a network path beside it producing the same result.
*/
class TranscriptSource(
private val settings: ServerSettings,
private val address: TranscriptAddress,
val cache: SessionCache,
) {
private val stream = AtomicReference<EventStream?>(null)
/**
* The cached opening window, or null when there is nothing usable to draw.
*
* Drawn *before* [probe] returns, which is the whole point of the feature: the rows are on
* screen while the check that they are still the server's rows is in flight, and a failed check
* replaces them exactly as a `reset` does.
*/
fun cachedOpening(limit: Int = OPENING_WINDOW): List<SeqEvent>? {
if (cache.tail() == null) return null
val lines = cache.newest(limit)
if (lines.isEmpty()) return null
return try {
lines.map { parseSeqEvent(it) }
} catch (e: org.json.JSONException) {
// Lines this build cannot read at all, which the cache's own checks cannot see: it
// reads a seq off a line, not an event. Nothing to serve, so a cold open.
cache.purge()
null
}
}
/**
* Whether the server's event at the cached cursor is still the cached one.
*
* The screen must not resume a stream from a cached seq unless it is the same conversation. A
* transcript is append-only in ordinary use, but the file 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, in the slot the opening page's request used to be in.
*
* False purges the cache and means "open cold". A throw is the server not being askable, which
* is neither: the cached rows stay on screen and the caller tries again on the reconnect
* schedule.
*
* What this cannot see is a line changed in the middle of the file with the tail intact. That
* is what the Reload button in session settings is for.
*/
suspend fun probe(): Boolean {
val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
// the cursor when the server still has one there.
val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
val matches =
answer.size == 1 &&
try {
answer[0].second == parseSeqEvent(tail.line)
} catch (e: org.json.JSONException) {
false
}
if (!matches) cache.purge()
return matches
}
/**
* Today's opening fetch, kept as the start of the live run. Only called when the cache has
* nothing to open with, or when [probe] said what it had was not the server's.
*/
suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush()
return page.map { it.second }
}
/**
* The page before [before]: from the cache when it holds it, otherwise from the server bounded
* by what the cache already has.
*
* The bound is what keeps the cache worth having. A coalesced page reaches back as far as its
* row count takes it -- a single reply is hundreds of lines -- so a page fetched after the
* reader has been away would run straight past the cached run and overlap it, and an
* overlapping page cannot be stored. Told where this phone's copy starts, the server stops
* there instead.
*/
suspend fun page(before: Long, limit: Int, coalesce: Boolean): List<SeqEvent> {
cache.page(before, limit, rows = coalesce)?.let { lines ->
DebugStats.count("transcript page from cache")
return lines.map { parseSeqEvent(it) }
}
DebugStats.count("transcript page from server")
val page =
fetchTranscript(
settings,
address,
before = before,
limit = limit,
coalesce = coalesce,
after = cache.coveredUpTo(before)?.minus(1),
)
if (page.isNotEmpty()) {
// `before` rather than the newest line's seq: a coalesced page covers everything up to
// the cursor it was asked with, and nothing in its lines says so.
cache.storePage(page.map { it.first }, page.first().second.seq, before, rows = coalesce)
}
return page.map { it.second }
}
/**
* [EventStream.run], with every frame written to the cache before [onEvent] sees it.
*
* 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 the 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.
*/
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, address)
stream.getAndSet(opened)?.close()
try {
opened.run(after, onOpen, onReset) { raw, entry ->
cache.append(raw, entry.seq)
if (entry.event is SessionEvent.Status) cache.flush()
onEvent(entry)
}
} finally {
cache.flush()
}
}
/** Ends the stream, from any thread, and leaves the cache with everything it was given. */
fun close() {
stream.getAndSet(null)?.close()
cache.flush()
}
}
/**
* How many events the screen opens with, cached or fetched.
*
* The server's own default for a page, 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.
*/
private const val OPENING_WINDOW = 80
/**
* Where this server's cached transcripts live.
*
* Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the
* server, which Android may delete under storage pressure without asking. Keyed by host and port
* because two servers can hold a session with the same id, and a line from one shown against the
* other is the whole invariant broken. `v1` is the layout's version.
*/
fun cacheRoot(context: Context, settings: ServerSettings): File {
val transcripts = File(context.cacheDir, "transcripts")
transcripts.listFiles()?.forEach { if (it.name != CACHE_VERSION) it.deleteRecursively() }
return File(transcripts, "$CACHE_VERSION/${settings.host}_${settings.port}")
}
private const val CACHE_VERSION = "v1"
@@ -1,407 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* One item of the transcript list: a whole row, or one block of a settled reply.
*
* The unit of laziness is deliberately smaller than a message. A lazy list pays to compose an item
* at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be
* twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when
* the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst
* frame is bounded. This is the piece that was missing when a lazy list was last tried here.
*
* Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit
* points back at its row. The list draws units; anchors and paging still speak seq.
*/
@Immutable
sealed class TranscriptUnit {
/** The list identity; must survive pages landing at either end. See [TranscriptRow.key]. */
abstract val key: Any
/** Where this unit's row starts in the transcript -- the anchor identity, never the key. */
abstract val seq: Long
/**
* This unit's position within its row, counted from the row's oldest end. What a saved scroll
* position carries besides the seq: a reply split into forty blocks needs more than "somewhere
* in this row" to put a reader back where they stopped.
*/
abstract val ordinal: Int
/** The gap drawn above this unit -- between rows, or between blocks of one reply. */
abstract val gap: Dp
/** A row drawn as itself: a bubble, a tool card, a group -- or the reply still arriving. */
data class Whole(val row: TranscriptRow, override val gap: Dp) : TranscriptUnit() {
override val key: Any
get() = row.key
override val seq: Long
get() = row.startSeq
override val ordinal: Int
get() = 0
}
/** One [Piece] of a settled reply; [text] is the prose it is a piece of. */
data class Block(
override val seq: Long,
override val ordinal: Int,
val text: String,
val piece: Piece,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "b$seq:$ordinal"
}
/**
* The heading of a message from another agent: who sent it, and the control that opens it.
*
* A peer message is the one row whose *opened* size is unbounded -- these are the longest
* things a transcript holds -- so it is flattened the same way a settled reply is, and for the
* same reason. Measured on the emulator, opening a 43KB one took the transcript's share of the
* draw phase from 0.81ms a frame to 3.85ms, and the framework's own per-frame bookkeeping from
* 0.39ms to 3.15ms.
*
* The card is drawn in pieces rather than given up: a filled Material card is elevation zero,
* so it has no shadow to break, and each piece paints the same fill with only the corners it
* owns.
*/
data class PeerHead(
override val seq: Long,
val item: TranscriptItem.PeerNote,
val open: Boolean,
override val gap: Dp,
) : TranscriptUnit() {
/**
* The note's own key, so opening and shutting does not change what the list is anchored on
* -- and so two notes stamped with one turn's seq are still two items.
*/
override val key: Any
get() = item.key
override val ordinal: Int
get() = 0
}
/**
* One [Piece] of an opened peer message; [last] is the piece that closes the card. Its [gap] is
* always zero -- the pieces are one card -- so the room between blocks is [spacing], drawn
* inside the piece where the card's fill covers it.
*/
data class PeerBlock(
override val seq: Long,
override val ordinal: Int,
val text: String,
val piece: Piece,
val last: Boolean,
val spacing: Dp,
override val gap: Dp,
/** The note this block belongs to; its key, not its seq. See [TranscriptItem.PeerNote]. */
val note: Any,
) : TranscriptUnit() {
override val key: Any
get() = "p$note:$ordinal"
}
/**
* One slice of a long user message; see [userChunks].
*
* A user message is plain text, so cutting it costs a scan rather than a parse -- but the
* reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand
* pixels of `Text` whose layout lands in the frame the row scrolls into.
*/
data class UserChunk(
override val seq: Long,
override val ordinal: Int,
val text: String,
val first: Boolean,
val last: Boolean,
/** The message's attachments, drawn under the words -- so only the last slice has any. */
val attachments: List<String>,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "u$seq:$ordinal"
}
/** One memory note of a settled reply; see [MemoryNote]. */
data class Memory(
override val seq: Long,
override val ordinal: Int,
val part: MessagePart.Remembered,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "m$seq:$ordinal"
}
}
/**
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start.
*
* Every settled reply is cut into its pieces (via the caches on [replies] so a message is only ever
* cut once), and so is an *opened* peer message -- [openNotes] is which ones those are. A shut one
* is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text
* changes with every delta, and splitting it here would parse the whole message per delta on
* whichever thread is composing. Once settled it splits like every other reply, which is what
* bounds the newest row's cost after a session ends on a long one.
*
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
* path: [ParsedReplies.partsOf] and [ParsedReplies.piecesOf] are lookups for any text [warm] has
* seen, and a miss -- the one message that just finished streaming -- costs its parse exactly once.
*/
fun transcriptUnits(
rows: List<TranscriptRow>,
replies: ParsedReplies,
openNotes: Set<Long>,
): List<TranscriptUnit> {
val started = System.nanoTime()
val units = ArrayList<TranscriptUnit>(rows.size)
rows.forEachIndexed { index, row ->
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
val item = (row as? TranscriptRow.Single)?.item
if (item is TranscriptItem.PeerNote) {
val open = item.seq in openNotes
units += TranscriptUnit.PeerHead(row.startSeq, item, open, rowGap)
// No gap between the pieces: they are one card, and a card with a stripe through it is
// what any spacing here would draw.
if (open) {
val pieces = replies.piecesOf(item.text)
var previous: Piece? = null
pieces.forEachIndexed { at, piece ->
units +=
TranscriptUnit.PeerBlock(
row.startSeq,
at + 1,
item.text,
piece,
last = at == pieces.lastIndex,
spacing = gapBefore(previous, piece),
gap = 0.dp,
note = item.key,
)
previous = piece
}
}
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like the
// markdown splits so the scan happens once per message rather than once per fold.
val chunks = replies.chunksOf(item.text)
chunks.forEachIndexed { at, chunk ->
units +=
TranscriptUnit.UserChunk(
row.startSeq,
at,
chunk,
first = at == 0,
last = at == chunks.lastIndex,
attachments = if (at == chunks.lastIndex) item.attachments else emptyList(),
gap = if (at == 0) rowGap else 0.dp,
)
}
} else if (
item is TranscriptItem.AssistantMsg &&
splitWanted(item, index, rows.lastIndex) &&
replies.splitReady(item.text)
) {
var ordinal = 0
fun gap(within: Dp) = if (ordinal == 0) rowGap else within
replies.partsOf(item.text).forEach { part ->
when (part) {
is MessagePart.Prose -> {
var previous: Piece? = null
replies.piecesOf(part.text).forEach { piece ->
units +=
TranscriptUnit.Block(
row.startSeq,
ordinal,
part.text,
piece,
gap(gapBefore(previous, piece)),
)
ordinal++
previous = piece
}
}
is MessagePart.Remembered -> {
units +=
TranscriptUnit.Memory(row.startSeq, ordinal, part, gap(BLOCK_SPACING))
ordinal++
}
}
}
} else {
units += TranscriptUnit.Whole(row, rowGap)
}
}
units.reverse()
reportDuplicateKeys(units)
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy" is
// this number growing, and it was invisible until it was written down.
DebugStats.record("units flattened", System.nanoTime() - started)
return units
}
/**
* Whether this reply should be drawn as blocks: settled, or anywhere but the newest row.
*
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are
* answered by different things: this one by the fold, the other by whether [warm] has run.
*/
private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) =
item.settled || index != lastIndex
/**
* The replies among [rows] that should draw as blocks but whose parses are not made yet.
*
* Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold
* is the reply that just finished streaming -- nothing warms live deltas. The session screen warms
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
* ready parses.
*/
fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> =
rows.mapIndexedNotNull { index, row ->
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
}
/**
* Above this many characters, a user message is drawn in slices rather than as one bubble.
*
* Not zero, because a bubble's width wraps its content: slices have to fill the row to look like
* one bubble, and forcing that on a short message would visibly widen it. A message past this
* length has lines that wrap, so its bubble is at the full width already and the slices match it
* exactly. Below it, one item of at most a few screens is nothing the list minds composing.
*/
const val USER_SPLIT_CHARS = 4000
/**
* Roughly how much text one slice holds -- bounded, like a markdown block, is the whole point.
*
* About one viewport of wrapped text: a slice is composed whole in the frame it scrolls into, so
* its size is a frame-budget decision, and one screenful keeps that to a few milliseconds on the
* phone. Smaller buys nothing -- the seams are free -- but the units multiply.
*/
private const val USER_CHUNK_CHARS = 1000
/**
* A long user message cut at line starts into slices of roughly [USER_CHUNK_CHARS].
*
* At newlines only, never mid-line: text layout runs per line, so slices that own whole lines stack
* back into exactly the lines the single `Text` drew, and a cut inside one would reflow it. The
* newline at each cut is dropped -- the boundary between two stacked slices *is* that line break. A
* single line longer than a slice (minified JSON, a base64 blob) stays whole in its slice, so a
* slice is bounded by the longest line rather than absolutely.
*/
fun userChunks(text: String): List<String> {
val chunks = ArrayList<String>()
var start = 0
while (start < text.length) {
if (text.length - start <= USER_CHUNK_CHARS) {
chunks += text.substring(start)
break
}
var cut = text.lastIndexOf('\n', start + USER_CHUNK_CHARS)
if (cut <= start) cut = text.indexOf('\n', start + USER_CHUNK_CHARS)
if (cut < 0) {
chunks += text.substring(start)
break
}
chunks += text.substring(start, cut)
start = cut + 1
}
return chunks
}
/**
* Says which two units share a key, before the list dies of it.
*
* A duplicate key is fatal -- `LazyColumn` throws, and the app goes down in the middle of somebody
* reading a conversation -- and all the framework's message carries is the key. When that key is a
* seq it names neither row, and there is no way back from it to how the two came to share one: it
* took an afternoon and a fixture that could reproduce it. Two lines here answered it immediately,
* naming both rows and the field they had in common ([TranscriptItem.PeerNote.arrived]).
*
* Always on, for the same reason [DebugStats] is: an instrument that is only in the build nobody is
* holding when it breaks is not an instrument. It costs one map over the units that were just
* built, beside a loop that already allocates one entry per unit.
*/
private fun reportDuplicateKeys(units: List<TranscriptUnit>) {
val seen = HashMap<Any, TranscriptUnit>()
units.forEach { unit ->
val had = seen.put(unit.key, unit)
if (had != null) {
android.util.Log.w("ai-app", "duplicate unit key ${unit.key}: $had AND $unit")
}
}
}
/**
* What is on screen right now, a unit at a time: what each one is and how tall it is.
*
* For the render report, and it is the line every "it is slow here" report has needed. The
* framework's own per-frame cost grows with how many nodes are *alive* rather than how many are on
* screen, so a screen holding one enormous item is slow in a way that no counter of ours
* distinguishes from a screen holding twenty ordinary ones -- and "2 units visible" says one of
* them is enormous without saying which. This says which.
*
* [first] is the index the list gave the first *unit*: the list also holds the waiting-messages
* slot at index zero and the history spinner past the end, and both are named here rather than
* silently reported as whichever unit is nearest.
*/
fun visibleUnits(units: List<TranscriptUnit>, visible: List<LazyListItemInfo>, first: Int): String =
if (visible.isEmpty()) " nothing on screen"
else
" on screen: " +
visible.joinToString(", ") { info ->
"${units.getOrNull(info.index - first).kind} ${info.size}px"
}
/** What a unit is, in a word, for [visibleUnits]. Null is one of the list's own non-unit items. */
private val TranscriptUnit?.kind: String
get() =
when (this) {
null -> "the list's own"
is TranscriptUnit.Block ->
if (piece.item == Piece.WHOLE_BLOCK) "reply block" else "list item"
is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading"
is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice"
is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.Whole ->
when (val row = row) {
is TranscriptRow.Tools -> "tool group"
// The class name rather than a word per kind: this is a diagnostic, and a
// `when` here would be one more place that has to gain a case whenever the
// transcript does -- silently naming a new row after an old one until somebody
// noticed.
is TranscriptRow.Single -> row.item::class.simpleName.orEmpty()
}
}
/**
* Where the unit named by a saved position sits in [units], or null if its row is not loaded.
*
* The row is found by [seq] and the unit within it by [ordinal], settling for the nearest older
* unit when the exact one is gone -- a reply regrouped by a page boundary can split into a
* different number of blocks than it had when the position was saved, and "a little above where
* they stopped" loses less than the newest end does.
*/
fun unitIndexFor(units: List<TranscriptUnit>, seq: Long, ordinal: Int): Int? {
var best: Int? = null
var bestOrdinal = -1
units.forEachIndexed { index, unit ->
if (unit.seq == seq && unit.ordinal <= ordinal && unit.ordinal > bestOrdinal) {
best = index
bestOrdinal = unit.ordinal
}
}
return best ?: units.indexOfFirst { it.seq == seq }.takeIf { it >= 0 }
}
@@ -1,40 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
/**
* Keyed [items], with anything repeating a key already used left out.
*
* A lazy list throws when two of its items claim the same key, and the throw happens during measure
* on the main thread -- so it is not an error the screen can show, it closes the app. That is a
* disproportionate answer to a list with a repeat in it, and it lands on the reader rather than on
* whoever produced the repeat: on 2026-08-31 the import list crashed on a Claude Code session id
* recorded under two project directories, which is an ordinary state of a machine.
*
* Every list in this app keyed on an id keyed it on an id *the server chose*, so all of them shared
* the hazard and none could rule it out locally. Hence one function they all go through.
*
* Dropping the repeat is right here because the key is the whole identity: two rows with one id are
* two rows every action would treat as the same thing. Where the duplicate means something, the fix
* belongs at the source, and this is only what stops a data problem from being a crash. It is
* counted so the render report says it happened rather than leaving a silently shorter list.
*
* The transcript's own list is deliberately not on this: its keys are made here rather than
* received, and it is the one list where an extra pass over the items is measurable.
*/
inline fun <T> LazyListScope.uniqueItems(
items: List<T>,
crossinline key: (T) -> Any,
noinline contentType: (T) -> Any? = { null },
crossinline itemContent: @Composable LazyItemScope.(T) -> Unit,
) {
val seen = HashSet<Any>(items.size)
val unique = items.filter { seen.add(key(it)) }
if (unique.size != items.size) {
DebugStats.count("list items dropped for a repeated key")
}
items(unique, key = { key(it) }, contentType = contentType) { itemContent(it) }
}
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff. Show more