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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

AutoVsync picks Fifo, which every backend supports.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:12:09 -04:00
irisandClaude Opus 5 1fcaa2d72d Complete routes.rs's table, which the docs now point at
AGENTS.md and PLAN.md were both carrying their own copy of the HTTP
surface, and the previous commit replaced those with a pointer to this
module doc comment -- which turned out to be missing ten routes that
exist: the four `/setups/{id}/importable*`, `/sessions/{id}/permission-mode`
and all five under `/models`. Naming it the source of truth is only worth
doing if it is one.

The two "later phases add" lines at the foot are gone. Setups replaced
`/hosts` in August and `/models` is the block just added above them, so
both were promising work already done.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:21:52 -04:00
irisandClaude Opus 5 edc39c7371 Thin the app's comments
The same pass the server had, on the Kotlin side: comments restating what
the code says are gone, and the ones recording a measurement, a constraint
or an incident are kept but cut to a few lines each. 6540 comment lines to
5674, and 920 lines off the app.

Two doc comments had drifted onto the item above the one they describe --
`contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s
equivalent on the server was fixed in the previous commit. Each is back on
its own item, which is the only non-comment line this diff moves.

The comments are reflowed to the column limit at their own indentation:
several were written wide, and ktfmt re-wrapped them into lines holding a
single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the
result, which is the check.

Left alone deliberately: this codebase's remaining comment density is high
because the comments carry things the code cannot say -- what a null means,
what a number was measured against, which bug a guard exists for. Of the
238 one-line doc comments in the app, five were pure restatement of the
name and were removed; the rest each say something the signature does not.

ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass;
cargo test (127), clippy --all-targets and fmt still clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:20:16 -04:00
irisandClaude Opus 5 79682f03a7 Condense the documentation and thin the server's comments
The markdown had accumulated a lot that was stale rather than wrong.
PLAN.md still described pi as the llama.cpp harness, a refcounted
LlamaServerManager, and a providers-by-hosts cross-product, all of which
were superseded or never built; it also carried a second copy of the HTTP
table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held
implementation checklists for work that has since landed. AGENTS.md
restated most of PLAN.md's design instead of being the working-notes
layer it says it is. 3225 lines of markdown to 2180, with the stale
sections gone rather than reworded.

On the server, comments explaining what the code already says are out and
the ones recording a constraint, a measurement or an incident are kept but
cut to a few lines each: 5504 comment lines to 4586.

Four doc comments in session/mod.rs, and one each in process.rs and
usage.rs, had drifted onto the item above the one they describe --
functions were reordered without them, so `stop_session`'s doc sat on
`set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on
`type Cached`. Each is back on its own item.

routes.rs's module table also claimed later phases would add `/hosts`,
which setups replaced.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:45:43 -04:00
iris e3e02d55f7 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt
2026-09-04 15:03:37 -04:00
irisandClaude Opus 5 a802522039 Keep visited transcripts on the phone
Reopening a session downloaded the conversation again, every time, over
the tunnel. It now draws from a copy of what the server has already sent
and asks for one event to check that copy is still current.

Per session, under cacheDir, the server's own event lines in chunks named
for the range they cover -- so a coalesced page, whose lines do not say
what they cover, still records it. Only the contiguous run ending at the
newest chunk is served; a gap is closed by paging through it, bounded by
`after` on /transcript so the page stops where the phone's copy starts
and can therefore be kept. Nothing is derived and stored: rows are a
rendering, and a cache of them would need throwing away on every change
to the fold.

Nothing here is load-bearing. Missing, evicted, damaged or unwritable all
degrade to the cold open this screen did before, and the check before the
stream resumes -- one request, one event -- is what stops a replaced or
truncated file being spliced onto a copy of a different conversation.
What that check cannot see, a line changed mid-file with the tail intact,
is what Reload in session settings is for.

Measured on the emulator against ui-sandbox, on a 505-event session:
reopening it costs one request for one event, including scrolling the
whole conversation back; a cold open is two requests and 100 events. A
reset after falling 300 behind fetched the gap as four coalesced rows
rather than re-fetching 104 events and discarding them. Every chunk was
checked line by line against what the server says for the range its name
claims, across the reset and the gap-fill.

transcript-bench.sh, same viewport content and gestures, before and
after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the
transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms
either way. Within the emulator's noise, which is what a cache must be:
it changes what is fetched, not what is drawn.

Building it also found that the server handed out the same transcript
line two different ways. serde_json's default float parser is not
correctly rounded, so a ts written as ...0757 came back from /transcript
as ...0755 while the SSE stream sent the original -- invisible on screen,
since a ts is drawn as a relative time, and visible here only because the
cache compares a line it holds against the server's answer. Fixed with
float_roundtrip, with a test that fails the moment it is dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:00:25 -04:00
irisandClaude Opus 5 74110b4d72 The reconnect loop does not reproduce; log who falls behind
Measured against a session streaming 20 events a second. Reopening one with
an anchor 1,800 events back connects 87-119 events behind, under
CATCH_UP_LIMIT's 200 -- the restore is two requests now, the opening page
and one span covering the whole distance to the anchor, where the report
was written against a restore that took thirteen. Three provocations, no
repeat connection between them: that reopen, twenty seconds backgrounded
while 415 events were produced, and a reset forced by dropping the limit to
5, where the app cleared, refilled and carried on without reconnecting.

So the TODO entry goes. What stays is the instrument that answered it:
every SSE subscriber now logs at debug the cursor it arrived with and
whether it was continued or reset, since nothing else could say. The app
sees a window arrive and cannot tell how far it had fallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:01:01 -04:00
irisandClaude Opus 5 45e631ab96 Delete TRANSCRIPT_RENDERING.md, which was all finished work
The file was a week's record of the transcript work, and everything in its
architecture, techniques and rejected sections has landed -- so it was a log
of how the code got here, which the code's own comments and AGENTS.md
already carry. Kept only what existed nowhere else: stream-bench.sh,
trace-draw.sh and the two emulator-loop traps go to AGENTS.md beside
transcript-bench.sh, along with the standing rule to run the benches either
side of a transcript change; GrapheneOS's broken System Tracing goes to
~/.claude/MACHINE.md, since it is about the phone rather than this project.

Its one unfinished item -- the reconnect loop after a restart onto a
streaming session, which may already have been fixed by the restore's
one-event-per-request fix -- moves to TODO.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:38:40 -04:00
irisandClaude Opus 5 7997eeb7f8 Colour markdown's tables and the addresses written in it
A table is recognised by its delimiter row, the only line of one that cannot
be anything else, and its header is the line above -- the single place the
scanner looks ahead. Colouring every `|` instead would have marked the pipes
of a shell command written in a paragraph.

Addresses come in two shapes: `<...>` needs a scheme's colon or an at sign
inside it and no whitespace, which leaves `<div>` alone; a bare `scheme://`
needs no closer, so where it ends is the decision -- the sentence's trailing
punctuation is given back, and so is a closing bracket unless one opened
inside the URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:47:18 -04:00
irisandClaude Opus 5 68c5180260 Colour markdown, which is the one language that is not tokens
The token scanner asks what a character is; markdown's meaning is where it
sits, so a `#` opens a heading at the start of a line and is an ordinary
character three words in. `MarkdownSyntax.kt` reads structure a line at a
time and then each line's prose left to right, and `spansOf` is the one
entry point that hides which of the two scanners a language got.

Conservative wherever a guess would be invisible: emphasis needs a closer on
the same line with no space beside either marker, so the `*p = *q` of a C
fragment opens nothing; an underscore may not start or end inside a word;
and an indented code block is left plain, since four spaces after a blank
line and four after a bullet are the same line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 02:37:32 -04:00
irisandClaude Opus 5 a401e6a7e3 Draw the line numbers beside the file, not inside it
The stretch is rendered on the box around the list, so everything in that
box bent with it -- the numbers included. `Modifier.overscroll` wraps the
effect's single node, so one effect cannot be rendered in two places and
the numbers cannot be excluded while they live in the rows. They had to
leave.

They now sit beside the scrolling box: the rows leave a spacer where the
numbers go and a `SubcomposeLayout` draws them there. That is the one
arrangement that keeps them level with their lines, which is the thing a
numbered listing may never get wrong. Which numbers exist *and* where each
one goes both come from the list's own `layoutInfo`, read in the measure
block -- and subcomposition happens during measurement, so it composes from
the answer the list has just produced rather than from one it read a frame
ago. A column translated by the scroll position could not do that: the
translation would be a layout read and current while the set of numbers was
a composition behind it, and during a fling the numbers would slide against
their lines.

Checked by sampling the screen at about 1kHz through a fling: 23,520 row
observations over 552 frames, every one with its number at exactly its own
top, no drift at any point. Also that the gutter holds its x while the text
scrolls sideways, and that a short file and an empty one still draw.

A consequence worth having: the numbers are no longer inside the
`SelectionContainer`, so selecting part of a file and copying gives the code
rather than the code with a number in front of every line. That follows from
where they are now rather than from anything asked for, and a copy was not
exercised.

The stretch itself still cannot be seen from this VM, so whether the numbers
now stay flat while the text bends is the thing to look at on the phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:51:47 -04:00
irisandClaude Opus 5 7b08a71e64 Stretch the whole file at the ends, not one line
`Modifier.horizontalScroll` builds its own overscroll effect per node, so
with a node per row only the line under the finger bent when the file was
dragged past either end and the rest sat still beside it -- the same
complaint as the offsets, one layer out.

There is an overload that takes the effect instead of making one, and it
leaves the rendering to the caller. So the viewer makes one effect, hands
it to every row, and renders it once on a box around the list: the file
bends as the block it scrolls as. This only works now that every row is
the same width -- rows that disagreed about where the end was would
disagree about when to stretch.

**Not seen working.** Measured on the emulator here: over-dragging well
past the end and capturing mid-gesture produces a frame with no stretch in
it at all, and the list's own vertical overscroll does not appear either,
so this VM cannot show the effect for any scrollable. Noted in
~/.claude/MACHINE.md so the next session does not spend the same half hour
on it. What was checked here is everything either side: the scroll still
reaches both ends, the position survives scrolling vertically, and nothing
else moved. The stretch itself wants a look on the phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:38:21 -04:00
irisandClaude Opus 5 457907087c Record why the viewer's rows all share one width
EXPLORER.md's decision 8 said "one shared horizontalScroll state", which is
what was built and is not sufficient on its own -- the reason is worth
having beside the decision rather than only in the code that now works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:40:02 -04:00
irisandClaude Opus 5 a074975d6f Scroll the whole file sideways, not each row by its own amount
Sharing one `ScrollState` across the viewer's rows was not enough to make
them move together. `Modifier.horizontalScroll` is a node per row, and each
one coerces the shared offset into *its own* range -- its content width
less its viewport -- so a short line's range is zero and it stayed put
while the long line beside it moved. Each row also writes `maxValue` on the
shared state as it measures, so how far the file could be dragged at all
was decided by whichever row happened to measure last, and changed as the
list scrolled.

Both go away once every row is the same width. `FileLines` now carries the
longest line in columns, and the viewer turns that into one content width
from a single character's advance -- arithmetic rather than twenty thousand
measurements, because the face is monospace -- and gives it to every row. A
tab counts as eight columns and deliberately upwards: over-estimating
leaves a little empty space past the longest line, under-estimating puts
the end of that line out of reach. The width is capped well under what
`Constraints` can carry, so a minified file is a scroll that stops early
rather than a crash.

Reported by Iris on 2026-09-04. Checked on the emulator against the
generated 1 MB file, whose lines run from one character to sixty-eight:
the file now moves as a block, the offset survives scrolling vertically and
newly composed rows arrive at it, and the far end of the longest line is
reachable. Also checked on the two cases the change had no reason to touch
-- a file narrower than the screen, which still does not scroll at all, and
an empty one, whose zero content width draws its one numbered line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:39:37 -04:00
irisandClaude Opus 5 ffc266bf3e Name the measurement files in the fixture's description
AGENTS.md lists what the explorer's sandbox tree holds and had not caught
up with the three sized source files added beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:18:00 -04:00
irisandClaude Opus 5 121a47da6e Keep the explorer's measurement fixture in the sandbox
The three file sizes the viewer's and the editor's limits were measured
against are built by ui-sandbox.sh now, beside the rest of the explorer's
fixture tree, so the figures in EXPLORER.md can be taken again instead of
each session inventing its own generated file and getting a slightly
different one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:17:46 -04:00
irisandClaude Opus 5 2c12274285 Measure the explorer, and cap edit mode at what it can carry
Three numbers, taken on the emulator through the app's own render report
and written into EXPLORER.md; the fixture tree the sandbox now builds is
what they were taken against.

The viewer's scan was on the main thread. Decision 8 said off it, and the
first version did it in a `remember` inside the composition, which is not
that -- 460ms of frozen screen on a 1 MiB file, long enough that the
accessibility tree cannot be read, which is exactly what "the app has
stopped" looks like from outside. It runs on Dispatchers.Default now, with
a spinner where the file will be. Reading a megabyte is otherwise fine:
the viewer is a row per line, and it opens and scrolls 28,660 of them.

Edit mode needed a cap, and not the one the plan expected. The cost that
matters is not the highlighting -- 40ms a keystroke at 128 kB, which is
survivable -- it is Compose laying out one enormous text in the field:
2,027ms per frame at 128 kB, with typed characters dropped, and no
response at all at 1 MiB. Switching highlighting off would have saved
nothing, since every arrangement of a single text field pays it. So
EDIT_LIMIT is 32 kB, the largest size actually measured as usable, and
above it the pencil is disabled with the reason in words beside it: a
disabled control teaches what the thing can do but cannot say why it is
off, and a reader who cannot edit a file they can plainly read would
otherwise conclude the app is broken.

`FileLines.of` is timed like everything else here, so the figure lands in
the render report rather than needing a harness to ask for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:16:57 -04:00
irisandClaude Opus 5 9c4d33273b Merge branch 'main' of git.arirex.me:iris/ai-app
The drag-right-to-go-back gesture was removed on the other side while the
file explorer was being built on this one, so the explorer's own use of it
goes with it: back inside the explorer is the arrow and the platform's own
gesture, which is what the rest of the app now has too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:00:48 -04:00
irisandClaude Opus 5 db55ed4a8f The file explorer on the phone
The other half of EXPLORER.md: a folder button on the session header opens
the machine's filesystem, starting where the session works.

It draws **over** the session in the same `Box`, so the session under it
stays composed -- its event stream keeps flowing, its draft and scroll
position stay where they were, and coming back from a file costs nothing.
Back steps one level inside it (editor, viewer, directory, parent) and only
closes from where it opened; the platform gesture, the button and the swipe
all go through the one function, so they cannot mean different things.

The viewer is a `LazyColumn` of lines rather than one `Text`, because text
layout is linear in the text and a twenty-thousand-line file in a single
`Text` measures all of it to draw a screenful. Lines do not wrap and share
one horizontal scroll, so a logical line is a visual line and the gutter
cannot come to number the wrong text; the gutter's width is measured from
the digit count of the line count in the style it is drawn in. The editor
is a `BasicTextField` with a `VisualTransformation` carrying the scanner's
spans, which is the one Compose API that colours a field's own text rather
than replacing the field.

`fileLanguage` reads the same table `fenceLanguage` does, so a language
added for fences is a language added for files.

A file that changed on the machine while it was open here refuses to be
overwritten and asks, with what each of the three answers costs. That is
the ordinary case, not the exotic one: an agent editing the file somebody
is reading is what this whole feature is for.

The speedometer moves off the header into the session settings dialog,
where the session's other about-the-session controls are, and the folder
takes a place between the usage chart and the cog -- widest scope to
narrowest, cog at the end, as Iris asked. Both benchmark scripts move onto
`ui-trace`'s new tap-by-label action in the same change, so the render
report is never unavailable and never pressed at a coordinate that has
stopped meaning anything; `app/bench-lib.sh` is what they share, and
`grep -n "tap [0-9]" app/*.sh` is the check.

Exercised on the emulator against the sandbox's new fixture tree, with a
screenshot or a ui-trace for each: the listing (dotfiles, directories
first, a symlink to a directory sorted with them, a name with a tab in it),
a highlighted file, binary, too big, a permission error, editing and
saving, the 409 and its Overwrite, back with unsaved edits, creating a name
that exists, creating one that does not and landing in the editor, an empty
directory, and `..` above the directory the session opened in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:58:59 -04:00
irisandClaude Opus 5 8881a40919 Remove the drag-right-to-go-back gesture
The screen no longer follows a horizontal drag. Back is the arrow at the
top left and the platform's own edge gesture, both unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:41:58 -04:00
iris 9fdab777b4 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-03 23:32:22 -04:00
iris 3bb178363d Draw an inline code chip behind the text instead of under it
The chip was the renderer's span background, and a span's background is
part of the text's own drawing: the text node paints the selection first
and the glyphs over it, so an opaque chip covered the selection and
selecting a sentence highlighted every word of it except the ones in
backticks. The previous fix let the selection show through by taking the
chip to 60% alpha, which is a compromise on both sides -- the chip is a
weaker step down from the page, and selected it reached #3C344F where the
words around it reached #776394.

There is a place that is under both, and a fenced block was already in it:
a modifier on the text rather than a style inside it. So `appendCodeChip`
takes the code span from the renderer's inline builder, keeps its style and
its space of padding either side but drops the background, and marks the
range; `LinkedText` draws those ranges in a `drawBehind`. The chip is back
to the full `rawSurface` fill (measured #11111B against a #1E1E2E page) and
a selection over it now lands at #776394, the same as the rest of the
sentence -- the fenced block's numbers exactly.

The geometry is one box per line, from the bounding boxes of the run's
first and last characters, taken as far as the line's `visibleEnd`. Not
`getPathForRange`: that is the shape of a *selection*, which runs to the
right edge of every line but the last, and a code span that wrapped left a
full-width empty chip behind on the line above -- twice in one fixture.
`visibleEnd` is the same rule the selection rectangle obeys, so the chip
stops where the selection stops instead of sticking its padding space out
past the end of a selected line.

Checked on the emulator against a fixture with chips in a heading, three
kinds of list item, a quote, a table cell and a link label, unselected and
under Select All, and a link with a chip in its label still opens. Cost,
against the same build without the change, streaming sixty paragraphs of
three chips each: measure 755ms against 776ms, record 327ms against 321ms,
transcript draw 0.22ms in both.
2026-09-03 23:32:17 -04:00
iris 4a9c547293 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-03 23:28:07 -04:00
irisandClaude Opus 5 cc7e4f63ef Read and change a machine's files from the backend
The first half of EXPLORER.md: server/src/files.rs, which lists a
directory, reads a file, writes one, and creates a file or a directory on
whichever machine a setup names.

Each operation is one small POSIX script run through `Transport`, the way
the import listing and the usage fetch already ask a machine a question,
so the local and the ssh case are one implementation rather than two that
drift. The path crosses as a positional argument and never as script
text; `PATH_PRELUDE` is the one line that gives a leading `~` its
meaning, because a shell expands a tilde in text and not in an argument,
and it is the far machine's home that has to answer.

A read has four answers -- text, binary, tooBig, or the machine's own
error -- because a binary file drawn as text and a big one cut off
silently are both wrong in ways the reader cannot see. A write carries
the sha256 the read reported and is refused with a 409 when the file has
moved on, which is what happens whenever an agent is editing the file
somebody is reading.

`Transport::capture_with_input` is the one description of "run this
there, with this on stdin", and `ship_attachment` moves onto it rather
than assembling a second ssh invocation of its own. It is also the only
capture that hands back the exit status, which is how the write says
"this is not the file you read" without that answer looking like a
failure.

Exercised on both transports against the sandbox -- ssh to this VM with a
throwaway key, since the quoting and the stdin path are what that proves
-- including a filename with an apostrophe, one with a tab, an unreadable
file, a binary one, one over the limit, and the 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:28:00 -04:00
irisandClaude Opus 5 9c4df43951 Record why autocompact cannot be handed to a driver yet
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:19:51 -04:00
irisandClaude Opus 5 aa6d9b256e Let a selection show through an inline code chip
Selecting a sentence highlighted every word of it except the ones in
backticks. An inline span's background is part of the text's own drawing
and the selection rectangle is drawn underneath it, so an opaque chip hid
the selection completely -- and there is no way to draw it over instead,
since the order is the text node's. The chip's fill is 60% now: measured on
the emulator, unselected it is #161622 against a #1E1E2E page, so it is
still a clear step down, and selected it moves to #3C344F, which is the
whole point.

This is what Iris's screenshot was showing. A fenced block was never
affected -- its background is on the box around the text rather than on
spans, so the selection lands on top of it, which is why it looked fine
when I went looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:19:27 -04:00
iris 1fc0f5c129 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-03 21:15:16 -04:00
irisandClaude Opus 5 5b1121be16 Take the card's tap back off the words, and let the words still be selected
Verified on the emulator with all three gestures on an opened peer message:
a tap on its words shuts it, a press held past the long-press timeout
selects from them, and the tap that puts that selection away does only
that. The earlier note in TODO.md saying markdown inside these cards could
not be selected was a coordinate error on my part -- the presses were
landing in the card's bottom padding, below the last line -- and it is
removed rather than carried forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:15:15 -04:00
iris f6bee1b8a5 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-03 21:07:07 -04:00
irisandClaude Fable 5.1 32c57a47a5 Plan the file explorer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:07:07 -04:00
irisandClaude Opus 5 acdf00ab1d Close a card by pressing its words, and let a selection be put away
A markdown paragraph took every tap that landed on its glyphs, so an opened
peer message or memory note could be shut anywhere except on the text --
which is most of it, and reads as a card that has stopped working. Measured
on the emulator: with a handler on the text the tap did nothing at all, and
with the handler removed the same tap shut the card. The words now do the
shutting, through a composition local, since the renderer composes those
paragraphs out of its own component table and there is nothing between the
card and them to pass a parameter through. The link handler is bounded by
the long-press timeout, so holding to select is not a tap.

The other half is the tap that puts a selection away, which used to shut
whatever card the words were in. The container clears the selection from
that same press, milliseconds before the card reads it, so the answer is
taken at composition instead -- what was true when the reader touched the
screen.

Selection colours are the app's own. Material's 40% of primary is a tint of
whatever is behind it, and over the near-black a code block sits on it
composited to a smudge, so selecting a line of code looked like nothing had
happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:03:15 -04:00
iris 359649bc73 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-03 20:24:10 -04:00
irisandClaude Opus 5 a2b11d516f Colour code with a scanner of our own instead of the library
dev.snipme:highlights 1.1.0 found comments before it knew the language and
paired /* with */ by ordinal, so `//` in any URL commented out the rest of
its line, every Rust `#[derive(...)]` greyed out as a comment, a `#` inside
a Kotlin string swallowed the line, and `x '*/a/*'` in shell produced a span
whose end preceded its start -- the one that crashed a card holding
`-path '*/.git/*'`. None of that could be post-processed away, because
comments won over strings before the language was known.

Highlighter.kt is one left-to-right scanner: at each position it is in a
line comment, a block comment, a string, or ordinary code, and every span is
emitted by advancing an index, so spans cannot overlap, arrive out of order
or run backwards. Languages.kt is a `Rules` row per language -- comment
tokens, block comment and whether it nests, the string forms, what opens an
attribute, and the keyword set -- so a new language is a table entry. The
keyword lists came from the library's SyntaxTokens.kt (Apache-2.0, noted at
the table) so nothing that is coloured today turns plain, and RON, TOML,
fish and JSON are coloured for the first time.

HighlighterTest.kt is a new JVM unit test source set -- 24 cases, the
library's mistakes kept as regressions, plus a sweep asserting no span
escapes the code for any language on unterminated and empty input.
AGENTS.md's app line now runs :androidApp:testDebugUnitTest.

Measured on the ai-app emulator, debug build, a ~200-line Kotlin fence sent
into a sandbox session:

  before  code highlighted: 1, 101.9ms total, 101.9ms mean, 101.9ms worst
  after   code highlighted: 1,  15.0ms total,  15.0ms mean,  15.0ms worst

and a second fence in the same run took 13.9ms, so that is the steady cost
rather than class loading. stream-bench.sh after the change:

  code highlighted: 1, 12.1ms total, 12.1ms mean, 12.1ms worst
  markdown reparsed while streaming: 1329, 2130.7ms total, 1.6ms mean, 8.7ms worst
  record: one block: 131, 11.4ms total, 0.1ms mean, 0.4ms worst
  draw phase 1.21ms per frame, the transcript 0.23ms of it

transcript-bench.sh after: draw phase 1.10ms per frame, the transcript
0.49ms (place 0.48), worst place 4.3ms -- unchanged within run-to-run noise,
as expected, since the scan happens in `warm` and not while drawing.

Looked at on the emulator: a URL inside a Kotlin string, a Rust attribute
with a lifetime and a raw string, a shell line with globs and `$#`, a RON
fence and a TOML fence all colour correctly; a Bash tool card still colours
its command; a plain Python fence -- which this change had no reason to
touch -- looks as it did; an unknown language stays plain; and a fence is
plain while it streams and colours when it freezes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:23:59 -04:00
irisandClaude Opus 5 a383c19dd5 Colour what a shell printed, swipe back, and let a stopped session take a setting
Bash output arrived with its escape sequences in it, so a coloured diff or
test run was line noise around the thing being read. The sequences that
decide how text looks are spans now and every other one is dropped, with a
carriage return honoured the way a terminal honours it so a progress bar
shows its final state rather than every state it passed through.

A rightward drag anywhere on a session, spawn or settings screen steps back,
following the finger so it can be abandoned. It loses every argument: a
child that consumes horizontal drags -- a wide fence, a table, a selection
-- has already taken the gesture before this sees it.

Changing the model or the permission mode of a session with nothing running
was refused, in words about the driver, while the config had already taken
the value that its next start will use. Both now announce the stored setting
instead, through one function, since which of the pair it is does not change
the rule.

The model-switch warning no longer fires after a clear: the server reports
the context as unmeasured rather than zero afterwards, and the fallback
reading counted the whole conversation still on screen.

An image loading shows a spinner in the space it is about to fill, in the
transcript and in the composer's attachments alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:17:12 -04:00
irisandClaude Opus 5 8dcd2cb708 Answer a question card as one act, and mark the press that made it
Picking an option marked nothing until the answer had crossed the tunnel,
been recorded and come back as an event, so the card sat unchanged for most
of a second after a tap. What the reader has picked is now the card's own
state and shows at once; a Submit at the foot sends every question the tool
is waiting on, greyed until all of them have an answer and a spinner while
the request is out.

Several questions are paged rather than stacked, with the count and a pair
of arrows on the right, because three questions with four described options
each is several screens and the reader scrolls past the one they are
answering to reach the button that sends it.

A permission ask keeps its single tap -- two bare words are not worth a
submit step -- and marks what was pressed until the request settles, so the
mark either stands on the recorded answer or goes away with the failure.

Chevron draws all four directions from one description of the shape, since
the pager needed two more of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:04:48 -04:00
iris a4d512af26 Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-03 19:56:41 -04:00
irisandClaude Opus 5 32b2a4871a Read a timeout in units, share one usage answer, and let a tilde mean home
The composer's settings row is outlined bubbles opening round menus, and
the message box is a TextFieldValue so anything put into it without being
typed -- a draft, a share, a slash command -- leaves the cursor at the end.

A tap that puts a text selection away no longer also collapses the card the
text was drawn in: every open and close on the session screen goes through
one guard that spends such a press on the selection.

The usage bar and the usage dialog were two polls of one measurement and
disagreed for up to a minute at a time; they are one feed now, and the
countdown rounds up to the minute in the one place both read.

A working directory typed as ~/repos/ai-app was four literal characters on
the local transport and as an argument on both, so the existence check
refused every home-relative path. It is checked by entering the directory
now, expanded for a local spawn the way the remote shell expands it, and
stored short so the phone draws what somebody would write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:56:36 -04:00
iris 3d90e0947c Merge branch 'main' of git.arirex.me:iris/ai-app 2026-09-03 19:42:39 -04:00
irisandClaude Fable 5.1 f6bc868d55 Plan the replacement of the code highlighter with a scanner of our own
The highlights library finds comments before it knows the language and
pairs quotes and comment delimiters by ordinal; measured on 2026-09-03,
that greys out every Rust attribute, comments out the rest of a line at a
URL or a # inside a string, and opens a bogus string at an apostrophe.
Upstream's only fix since 1.1.0 is unreleased and covers just the
reversed range we already drop. The plan replaces it with one sequential
scanner and a table of languages, adding RON, TOML, fish and JSON, which
real transcripts use and the library never knew.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:42:32 -04:00
irisandClaude Opus 5 2970a6cf9a Write down the working list
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:41:36 -04:00
irisandClaude Opus 5 c0121d2b5a Prune the transcript doc to what is still live, and measure the highlighting bug
The rendering doc's running log of finished work is folded into the
architecture section and the rest dropped: what remains is why the code
is the way it is, the harness, and what is next.

The highlights bug is bigger than the backwards span we already drop.
The library pairs every `/*` with an `*/` by ordinal position and uses
the same delimiters for every language, so a shell glob opens a comment
and `//` in any URL comments out the rest of its line, taking the
keywords and strings inside it with it. highlights-repro.sh asks the
library directly, outside the app, since none of this is visible on a
phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:16:42 -04:00
irisandClaude Opus 5 b0b91ad28f Draw a fence plain while it is still being written
Warming covers a settled message, so it did not reach the block a reply is
still writing: that one was re-lexed at every delta, on the composing
thread. Measured streaming a two-hundred-line Kotlin fence -- 211 lexes,
13.7 seconds across the turn, the worst 177ms -- for colours on text being
replaced as fast as they were computed.

A live reply's last segment now draws its code plain and takes its colours
when the block freezes, which for a finished fence is as soon as the next
block starts. The settle-time lex then happens in warm, off the drawing
thread. Same fixture after: one lex of 374ms in warm, draw phase 1.51ms to
1.01ms per frame while streaming, and the fence coloured on screen once the
reply settles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:58:55 -04:00
irisandClaude Opus 5 ab6a797941 Warm a fence's highlighting like a parse, and page the restore by rows
Two things the measurements for the previous commit turned up.

Highlighting a fence cost 174ms for a two-hundred-line Kotlin block, and
the lazy list charged it again every time that block scrolled back into
composition -- six times in one bench run, with the scroll's draw phase at
1.29ms per frame. So it is warmed and cached where parses already are:
`highlight` is a plain function taking no colour from the theme, `warm`
fills `ParsedReplies.highlighted` from `fences(parse)` off the drawing
thread, and `fenceContent` extracts the code here rather than through the
library's composable, so the string warmed is the string drawn.

And the anchor restore asked for a span counted in events, which goes
negative when the anchor's row is the oldest half-row and was coerced to
one -- a request per delta, six hundred round trips walking one reply back
a word at a time with the spinner up. It asks for a page of rows now.

Clean pairs, fresh sessions each side, same gestures. Fence scroll
(transcript-bench.sh): draw phase 0.74ms per frame before highlighting
existed, 0.77ms after, no lexing in the window either side. Streaming
forty linked items (stream-bench.sh): 2412ms of reparsing before, 674ms
after; mean 5.0ms to 1.4ms, worst 8.9ms to 7.9ms. Bullet glyphs, fence
colours, the image links and the reference link checked on the emulator;
lint clean on AGP 9.4.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:50:29 -04:00
irisandClaude Fable 5.1 6892dc7caf Colour list markers by depth, highlight fences, draw images as links, and stream a list an item at a time
Items 1-5 of TRANSCRIPT_RENDERING.md's list, plus the AGP 9.4.0 bump from 7.
MarkdownRoot provides the renderer's locals itself instead of calling its
Markdown() composable; fences and indented blocks go through CodeFence.kt,
which shares the tool-input highlighter and a fence-language alias table;
an image in a paragraph is a link carrying its alt text, so every paragraph
is now platform text; LiveParse freezes the finished items of the tail list
so a forty-item list streams as forty paragraphs would.

Measured before, on the emulator (report from transcript-bench.sh over the
200-line fence fixture): draw phase 0.72ms per frame, transcript 0.36ms.
stream-bench.sh (new) streaming forty linked bullets on the old build:
markdown reparsed while streaming 483, 3.9ms mean, 11.6ms worst; record:
one block worst 1.6ms. The after runs, the on-screen check of the glyphs
and lint are recorded as owed in the doc's "What is next".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 13:49:51 -04:00
irisandClaude Fable 5.1 801618ba0e Stream attachments end to end, ship files to a remote session's machine, and write up the transcript work
Uploads no longer sit whole in memory anywhere: the phone writes the
multipart body chunked as it reads the picked file, and the server writes
each chunk to a `.part` file under the session and renames it when whole.
The per-request cap is 4 GB and bounds disk, not memory.

A file attached to a session on another machine is copied there in the
same request: one ssh invocation takes the bytes on stdin into the
setup's `attachmentsDir` (new, optional, on the machine form and in the
config), else the session's cwd, else the login home, and answers with
`pwd -P`, which is recorded beside the file as `<name>.remote` and is the
path the driver tells the CLI. A failed copy fails the upload and says
why, so no message ever names a file that is not there. The host keeps
its copy so transcripts can reference and fetch it. Measured against the
Gentoo test guest: a 40 MB file shared from the phone arrived there byte
for byte. The tilde in that setting is the remote home, so it is not
expanded on the server the way other setup paths are.

TRANSCRIPT_RENDERING.md records the week of transcript work -- the
measurements behind each decision, the harness, what was rejected, and
what to do next -- so a new session can start from it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 13:24:36 -04:00
irisandClaude Fable 5.1 6180663f14 Attach any file, take shares from other apps, and survive a backwards highlight
Attachments were images only. Now any file can be attached: from the file
chooser behind the "+" menu, or from Android's share sheet, which the app
is now in. An image still goes to the model as a picture; anything else is
stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`)
and the Claude driver ends the message with `Attached file: /abs/path`,
since the CLI reads files by path and a model cannot be shown a trace. The
user-message field is renamed `images` -> `attachments` on both sides,
with a serde alias reading the rows written before. A share arrives before
anyone has said which session it is for, so it is held in AppRoot with a
banner on the list until a session takes it; an open session takes it at
once. Unreadable shares are reported beside the composer, not thrown.

The tool card crashed the app when opened on a command holding a quoted
glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers
`x '*/a/*'` with a span whose end is before its start, and AnnotatedString
refuses the range. Such spans are dropped; the library is the place for
the fix. The echo driver gains `/bash <command>` so a card with a given
command can be produced on the emulator.

ui-sandbox.sh's token salvage read the tokens block's close only at a line
start, ran past the compact `),],` the server writes, and copied `setups`
into the new config twice, which the server then refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 08:15:10 -04:00
irisandClaude Fable 5.1 4bc69e8f9c Draw text on the platform directly, own the table's outer box, and parse a live reply a block at a time
Three costs left in the renderer's composition layer, taken one at a time:

The text leaf. Every paragraph went through the renderer's text composable,
which exists to place inline images and charged each text for the
possibility: a placement callback, a derived map of inline content, a
semantics group and a size animation. A paragraph with no image -- nearly
all of them -- now goes straight to BasicText, with the renderer's own rule
for a style that names no colour. One with an image keeps the old path.

The table. The renderer decided "spread or scroll" with a BoxWithConstraints,
a subcomposition. LinkedTable does it with one layout modifier placed after
the horizontal scroll: fillMaxWidth fixes the minimum to the room, the scroll
passes that minimum through while lifting the maximum, and the modifier sizes
the rows to the larger of the room and the columns' floor. Rows no longer
need a width handed to them or a row index from a composition local.

The live reply. Every delta reparsed the whole message off-thread; for a
long reply that was tens of milliseconds hundreds of times, every core busy
while the frame's thread waited. LiveParse freezes every top-level block that
a later block has started after -- markdown's block rules make that safe --
and reparses only the tail. Each piece is keyed by where it starts in the
message, so a block keeps its composition when it freezes.

Verified on the emulator: the block-kind fixture draws the same with links
opening from a paragraph and a bullet and plain text opening nothing; a
six-column table still scrolls sideways; a 58-word mixed stream of list,
fence, table and quote drew every block as it arrived, 47 tail reparses at
1.7ms mean against whole-message parses before. ktfmt, build and lint clean
but for the AGP 9.4.0 notice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 00:00:01 -04:00
irisandClaude Fable 5.1 48bb7de304 Draw replies as pieces of one parse, and lists an item at a time
A settled reply used to be cut into block *strings*, each parsed on its own
and each a unit of the lazy list; the live reply split the same way with a
whole-message parse per delta on top. Now a message is parsed once, and a
Piece addresses a top-level block of that tree -- or one item of a
top-level list, which was the one block still unbounded: a list of forty
sources was one item composed whole in the frame it scrolled into. Units,
the live reply's column and peer messages all draw from the same parse,
so warm parses each message once instead of once per block, a delta costs
one background parse instead of two, and a reference definition at the
foot of a message resolves again because nothing is parsed apart from it.

The renderer keeps parsing and providing its environment; MarkdownRoot
wraps that around a piece, and a whole block still goes through its
dispatch with our component table. List items are drawn here, with the
renderer's own paddings so a split list looks like an unsplit one, and
lists inside quotes come to the same code through the table -- the marker
is drawn in one place, which is what a styled bullet would need later.

Found on the way: a heading's words are a child of the heading node, and
the inline builder draws nothing for a node type it does not know, so the
span-link path had been drawing headings empty. LinkedHeading hands it the
content child.

Lint: profileable's shell attribute scoped to API 29 where it exists, and
recordFrames renamed to the composable convention. What remains is the
AGP 9.4.0 notice.

Verified on the emulator against a fixture of every block kind (headings,
nested and ordered lists with a start number, task items, a quote holding
a list, a fence, a rule, a table with a linked cell, a setext heading), a
forty-item list which the render report now shows as per-item units, a
reply streamed live (34 deltas: 34 background reparses, one warm at
settle, no crash), and the older link fixture. ktfmt, build and lint run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 19:34:41 -04:00
irisandClaude Fable 5.1 a9ab9b3e5a Draw table cells as span-linked text, and hit-test links by glyph
Table rows are now ours: each cell is a LinkedText, so a link in a cell is a
span with a string annotation and one tap detector per cell rather than the
layout node Compose builds for every LinkAnnotation -- the cost the paragraph
change removed everywhere else. The renderer's outer table (width, sideways
scroll, corners, dividers) stays; the row and cell were the only parts it
offered no slot for. Cells still wrap and align to the top, with the same
semantics the renderer gave them.

Found while checking it: the hit test took the layout's nearest caret as the
glyph under the finger, so a tap on the right half of any link glyph named
the character after it and opened nothing. That was flaky in paragraphs
already; it now checks the glyph on either side of the caret.

Verified on the emulator: a linked cell and an autolink cell open their
addresses, a plain cell opens nothing, a six-column table still scrolls
sideways, and a link-free table draws as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:58:37 -04:00
irisandClaude Fable 5.1 3c3b4e62fd Declare build modes and the Enroll command for Dev Updater
modes: [release, debug], release first as the default; build-apk.sh
already takes the mode as its last argument, which is how Dev Updater
passes it. enroll: goes through server/enroll-link.sh rather than the
binary directly, because the command is handed the app's mode too and
because the server may have been built in either profile -- the wrapper
ignores the word and takes whichever ai-server exists, release first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 06:24:18 -04:00