Author SHA1 Message Date
iris 62199aa3a7 Merge remote-tracking branch 'origin/rustify' into worktree-agent-afe80868604fef704
# Conflicts:
#	IRIS.md
#	RUST.md
2026-09-05 12:59:39 -04:00
irisandClaude Fable 5.1 b133d85943 RUST.md, IRIS.md, CLIENT_CORE.md: record E4 done
RUST.md: E4 ticked with the screenshot path, the exact commands against
app/ui-sandbox.sh, and the streaming-duplication bug the screenshot found;
"Where things stand" moved E4 out of "in flight" into its own done bullet.
IRIS.md: transcript_ui::build_tree, the public API change transcript-ui
gained for this. CLIENT_CORE.md: client_core::config's table row and its
correspondence note.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

AutoVsync picks Fifo, which every backend supports.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:12:09 -04:00
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
292 changed files with 54991 additions and 9557 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
# every crate in this repo into one workspace -- they are deliberately
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
# from the repo root runs xtask/src/main.rs directly.
[alias]
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
+17
View File
@@ -55,4 +55,21 @@ components: [
// the terminal the QR would be printed on.
enroll: "server/enroll-link.sh",
),
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
// with the same release key as "app" above so the two can install
// over each other -- a separate component, not a mode of "app" above,
// because it is a different applicationId (com.example.aiapp.shell)
// built by a different tool from different sources. No `cwd`: it
// defaults to this checkout's root, which both the `cargo xtask`
// alias (`.cargo/config.toml`, resolved relative to the working
// directory cargo is run from) and `cargo xtask apk`'s own publishing
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
// both need.
Apk(
name: "shell",
modes: ["release", "debug"],
build: "cargo xtask apk",
),
],
+19
View File
@@ -1,12 +1,20 @@
.gradle/
build/
app/androidApp/build/
app/shellApp/build/
local.properties
.kotlin/
*.iml
.idea/
.DS_Store
server/target/
event-model/target/
client-core/target/
android-shell/target/
# E3's native library, built by cargo-ndk straight into the Gradle module
# (RUST.md) -- an artifact, like server/target/ above, not source.
app/shellApp/src/main/jniLibs/
# Server logs from a development run (ai-server.log by convention,
# wg-test.log from ./test-wg-tunnel.sh).
@@ -21,3 +29,14 @@ certs/
config.ron
config.json
sessions/
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
iris/target/
iris/android-app/target/
# E5's packaging xtask (RUST.md). `build/` above already covers
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
# The repo root has no Cargo workspace, so this is xtask's own
# intermediate working files (target/xtask/apk/...), not a shared one.
xtask/target/
/target/
+460 -772
View File
File diff suppressed because it is too large. Load diff
+146
View File
@@ -0,0 +1,146 @@
# client-core
`client-core/` is the app's pure logic held once instead of twice, per
RUST.md's recommendation item 1. It is a plain Rust library crate with no UI
framework dependency of any kind, so it can outlive whichever one the app
ends up drawing with (Masonry, iris, or something else -- see RUST.md).
`event-model/` is its sibling: the wire shape both this crate and `server/`
share, extracted from `server/src/session/driver.rs` and
`session/transcript.rs` on 2026-09-04.
Neither crate is wired into anything yet. `server/` re-exports `event-model`
so its own behaviour is unchanged (`./run-tests.sh` covers it); `client-core`
has no caller -- it exists for whichever experiment in RUST.md picks it up
next (a Masonry or iris transcript screen, most likely).
## What's here, and what Kotlin file it replaces
| `client-core/src/…` | Kotlin original | Status |
|------------------------------------------|-------------------------------------------|--------|
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
| `api.rs` | `Api.kt` | Partial -- see below |
| `event_stream.rs` | `EventStream.kt` | Done |
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
| *(not started)* | `TranscriptSource.kt` | Not started |
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
cases ported alongside it, plus new tests for the pieces that had none
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
types carry no logic of their own to test -- `server/`'s own tests exercise
them via `session::transcript`'s round-trip coverage).
## Correspondence notes worth knowing before touching either side
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
non-overlapping spans. Whatever UI framework ends up consuming this
crate maps `Style` onto its own text-styling type; nothing here should
change to accommodate a particular one.
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
strings. `highlight::span_text` turns a `Span` back into text for a
caller working the same way; a caller that wants byte offsets into a
`&str` has to convert.
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
translation bug while it was being written: an early draft let a
*damaged* chunk (one file unreadable, discard just this session) and a
genuine I/O failure (disk gone, disable the whole cache) both surface as
the same `Err` from one closure, which would have disabled every
session's cache over a single corrupt chunk. Fixed by checking a
thread-local "was this damage" flag before deciding which failure mode
it was -- see the comment on `guard` and the commit message for
`transcript_cache.rs`.
## What `api.rs` covers, and what it does not yet
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
`ApiClient` and `event_stream::follow_session_events` are tested with a
fake transport and no server). `UreqTransport` is the only real
implementation, backed by `ureq` -- see its Cargo.toml comment for why
(blocking, already a project dependency, no extra TLS crate needed since
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
Covered: session list/read, message send, unqueue, answer, interrupt,
stop, start, rename, cwd, model, permission-mode, notify, command,
compact, delete, and one transcript page.
**Not covered, and each is real work rather than a stub to fill in:**
setups (`/setups*`, machine and provider discovery), the file explorer
(`/setups/{id}/dir|file`), usage (`/usage`), models
(`/models*`, HuggingFace browsing and downloads), attachments
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
the full table to work from when one of these is next.
## What `transcript_fold.rs` covers, and what it does not yet
`fold_event` covers every `Event` variant server/ can produce today,
including tool-call/question/image attachment and peer-message placement.
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
merges a tool call split across two fetched pages and re-merges a run a
boundary cut through. This matters the moment paging backward through
history is exercised; it is deliberately left rather than rushed, since
it is exactly the kind of boundary logic this project's own "things that
have bitten" section warns reads fine and is wrong at the edges.
**Known gap, and a decision for whoever closes it:** `event_model::Event`
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
A server newer than this build that adds an event type will fail to parse
that line rather than degrading to a placeholder row. Closing this means
deciding how `event_model` itself represents "a shape I don't recognise"
-- a shared-model decision affecting `server/` too, not a `client-core`-only
fix, so it is recorded here rather than silently worked around.
## `config.rs`: `EnrolledServer`
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
parses on the phone -- so any Rust client enrols from the same text a
phone would scan as a QR, with no second format invented for it (RUST.md's
E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is
persisted or under what file permissions -- a phone seals its token in the
Android Keystore, `iris/desktop-app/src/config.rs` writes it to
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
caller-specific (the code rules' "ask for the least you need"). Its only
caller today is `desktop-app`; a future Android build of this crate would
be a second one, not a reason to move the type.
## What is not started at all
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
from the transcript cache or the server, and stitches the two. Needs
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
which exist now, so this is unblocked whenever picked up.
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
colours a `.md` file or fence for the highlighter, but does not build the
block tree (headings, lists, tables, fences as distinct nodes) that a
renderer walks to lay out prose versus code versus a table.
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
AST is Compose rendering plumbing, not something to port as-is; a Rust
UI layer will want its own block parser or a crate for it, decided
alongside the framework choice in RUST.md.
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
it flattens a row into bounded units for a *specific* lazy-list
framework's composition cost, which is a fact about that framework
rather than about the transcript.
## Verifying
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
and `server` in that order (each `cargo test`, forwarding arguments the
same way it always has). From `client-core/` directly: `cargo test`,
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
+30
View File
@@ -0,0 +1,30 @@
# Decisions taken for Iris to review
Short list of design choices made by the design agent without asking, so
they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone.
## 2026-09-05
- **Touch drag on a transcript row follows Android's own rule**: a vertical
drag pans the list immediately; a stationary press held 500 ms starts a
text selection which further dragging extends; a horizontal drag while
something is already selected extends that selection without the wait.
One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a
"text layer always wins" or "list always wins" rule because either loses
one of the two gestures a reader expects.
- **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window
holding `transcript-ui`'s screen beside a session list, talking to a real
`ai-server` through `client-core`. It enrols by pasting the same
`aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`)
and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The
pinned CA is a path given on the command line, not baked in. Chosen so
the phone and desktop share one enrolment format and no second one is
invented.
- **Order of remaining work**: finish the two in-flight pieces above, then
the transcript screen's Android integration and the `transcript-bench.sh`
comparison against Compose — the numbers the recommendation still lacks.
- **DEFERRED — whether to commit to iris over Masonry for `ai-app`.** Waits
on the bench numbers above; RUST.md's recommendation says what the
measurements must show.
+247 -349
View File
@@ -1,212 +1,212 @@
# The file explorer
Asked for by Bryan on 2026-09-03: replace the session screen's debug
button with a folder icon that opens a file and directory viewer for the
machine the session runs on. Browse directories, open files with the
existing syntax highlighting, line numbers, no wrapping; edit a file behind
a pencil icon; create files through a modal like the ones the app already
has; work over ssh; open at the session's working directory.
Asked for by Bryan on 2026-09-03 and built the same day: browse a machine's
directories, open files with the existing syntax highlighting and line
numbers, edit behind a pencil, create through a modal, work over ssh, and
open at the session's working directory.
This is the plan. Like PLAN.md it records each decision with the reason and
what was rejected, so that when one changes it is changed here rather than
re-argued. Once built, the operational notes (how to test it, what bit)
move to AGENTS.md and this file keeps only the design.
This is the design, decision by decision with the reason and what was
rejected, so that when one changes it is changed here rather than re-argued.
The operational half — how to run it and what to produce on purpose — is in
AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
`FileViewer.kt` / `FileEditor.kt` / `FileLines.kt` are the app.
## What it is, in one paragraph
A machine's filesystem, seen from the phone through the backend. The
explorer belongs to a **setup** (a machine), not to a session: a session
only says where to start. Every operation -- list, read, write, create --
is one shell script run through `Transport`, exactly the way the import
listing and the usage fetch already work, so the local and the ssh case
are one implementation and a machine the backend cannot reach fails with
ssh's own message. The phone draws what came back: a listing, a file with
its lines coloured by the scanner in `Highlighter.kt`, or an editor over
the same text.
A machine's filesystem, seen from the phone through the backend. The explorer
belongs to a **setup** (a machine), not to a session: a session only says
where to start. Every operation list, read, write, create — is one shell
script run through `Transport`, exactly the way the import listing and the
usage fetch already work, so the local and the ssh case are one
implementation and a machine the backend cannot reach fails with ssh's own
message. The phone draws what came back.
## Decisions
### 1. Keyed on the machine, opened from the session
Routes live under `/setups/{id}/…`, beside `importable`, because a
filesystem is a property of a machine. The session screen's folder button
opens the explorer with the session's setup and its `cwd` as the starting
directory; a session with no `cwd` opens at the machine's home, which the
machine resolves (`cd` with no argument and `pwd -P`), never a path the
phone guessed. Nothing in the explorer knows what a session is, so a later
entry point from the setups tab is one more caller and no new code.
Routes live under `/setups/{id}/…`, beside `importable`, because a filesystem
is a property of a machine. The session screen's folder button opens the
explorer with the session's setup and its `cwd`; a session with no `cwd`
opens at the machine's home, which the **machine** resolves (`cd` with no
argument and `pwd -P`), never a path the phone guessed. Nothing in the
explorer knows what a session is, so a later entry point from the setups tab
is one more caller and no new code.
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
find the setup, and "browse this machine" from anywhere but a session would
need a session to exist first.
find the setup, and "browse this machine" from anywhere else would need a
session to exist first.
### 2. One shell script per operation, over `Transport`, on both transports
Each operation is a small POSIX shell script handed to `sh -c script sh
"$path" …` through `Transport::capture` (or the stdin-carrying variant
below). The path and every other value cross as **positional arguments**,
never interpolated into the script -- the same rule `import::find` follows
with `"$1"`, and the same reason `ssh::quote` exists: a path is
attacker-adjacent input in a server whose job is running commands. A `~`
prefix is handled by the same `quote_path`/`expand_home` pair every other
path goes through; nothing new is invented for it.
Each operation is a small POSIX script handed to `sh -c script sh "$path" …`
through `Transport::capture` (or `capture_with_input`). The path and every
other value cross as **positional arguments**, never interpolated into the
script the same rule `import::find` follows and the same reason
`ssh::quote` exists: a path is attacker-adjacent input in a server whose job
is running commands. `PATH_PRELUDE` is the one line that gives a leading `~`
its meaning, since a shell expands a tilde in text and not in an argument.
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
`sha256sum`, `chmod --reference`). That is already what `import.rs`
assumes (`stat -c`, `/proc`), and both machines that exist are Linux. A
machine without them fails with that tool's own message, which names what
is missing.
`sha256sum`, `chmod --reference`) already what `import.rs` assumes, and
both machines that exist are Linux. A machine without them fails with that
tool's own message, which names what is missing.
Rejected: `std::fs` for the local transport and scripts for ssh. Two
implementations of "list a directory" drift -- the ordering of entries,
what a symlink reports, how a permission error reads -- and the local one
is the one that gets tested, so the remote one ships broken. The transport
design exists so that a driver never learns which machine it got; the
explorer is held to the same rule. The cost is a `sh` process per
operation locally, which is under a millisecond.
implementations of "list a directory" drift the ordering of entries, what a
symlink reports, how a permission error reads and the local one is the one
that gets tested, so the remote one ships broken. The cost is an `sh` process
per operation locally, which is under a millisecond.
Rejected: a Rust SSH or SFTP library. PLAN.md rule 23 -- the system `ssh`
inherits `~/.ssh/config`, agents and jump hosts, and there is one place to
configure a connection. SFTP would need a second one.
Rejected: a Rust SSH or SFTP library. The system `ssh` inherits
`~/.ssh/config`, agents and jump hosts, and there is one place to configure a
connection; SFTP would need a second.
### 3. The token can now name a path, and that is written down
AGENTS.md says of the import route: "the phone picks an **id**, never a
path: the server resolves which file that is, so an enrolled token cannot
become 'read me an arbitrary file'." The explorer's whole purpose is the
path, so it takes one. This is recorded in PLAN.md's Security section as a
change to the threat model paragraph, in these terms: the token already
gates spawning a bypass-permissions agent in any directory on any machine
a setup names, and that agent can already read and write every file its
user can. The explorer is a shorter path to authority the token already
holds, not new authority. The import route's rule stands where it is,
because there a path was unnecessary and refusing it cost nothing.
Elsewhere the phone picks an **id** and the server resolves which file it
names, so an enrolled token cannot become "read me an arbitrary file". The
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
Security section in these terms: the token already gates spawning a
bypass-permissions agent in any directory on any machine a setup names, and
that agent can already read and write every file its user can. The explorer
is a shorter path to authority the token already holds, not new authority.
The import rule stands where it is, because there a path was unnecessary and
refusing it cost nothing.
What is *not* changed: no route accepts a command. Listing, reading and
What is *not* changed: **no route accepts a command.** Listing, reading and
writing are fixed scripts; the phone chooses only the path and the bytes.
### 4. Paths are absolute or `~`-prefixed, and the machine answers with the real one
Same rule as `POST /sessions/{id}/cwd`: a relative path is refused with
the same wording, because where it would be depends on where nothing the
reader can see. Every listing answers with `pwd -P` of the directory it
listed, so the phone navigates on a resolved absolute path -- the parent
of `/home/bob/repos/ai-app` is a string operation on that, and a `~` the
session was spawned with is shown as what it turned out to be. The phone
never resolves `..` itself.
Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where
a relative path would be depends on something the reader cannot see. Every
listing answers with `pwd -P` of the directory it listed, so the phone
navigates on a resolved absolute path the parent is a string operation on
that, and a `~` the session was spawned with is shown as what it turned out
to be. The phone never resolves `..` itself.
### 5. A read is capped and typed, and every state it can be in has a word
`GET /setups/{id}/file` answers with one of:
- `text` -- the content, with its size, mtime and sha256.
- `binary` -- the content is not UTF-8. Size reported, nothing shown.
- `tooBig` -- over `FILE_LIMIT` (1 MiB to start; see "Numbers to
measure"). Size reported so the reader knows what they are looking at.
- an error -- no such file, permission denied, machine unreachable --
carrying the machine's message.
`GET /setups/{id}/file` answers with one of `text` (content, size, mtime,
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
at), or the machine's own error.
Four outcomes rather than content-or-error, because a binary file drawn as
text and a big file cut off silently are both wrong in ways the reader
cannot see, and "couldn't read it" must not look like "it is empty". An
empty file is `text` with empty content and is drawn as one empty line
numbered 1, which is what it is.
Not in the first cut: showing images (the phone has `isImageRef` and a
viewer already; the route would serve bytes). Listed under "later".
text and a big file cut off silently are both wrong in ways the reader cannot
see, and "couldn't read it" must not look like "it is empty". An empty file
is `text` with empty content, drawn as one empty line numbered 1, which is
what it is.
### 6. A write is conditional on what the reader saw
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
compares it against the file as it is now and refuses with a distinct exit
code if it differs; the server answers **409** with "changed on the machine
since you opened it". Agents edit files while people read them; this is
the common case, not the exotic one, and silently overwriting an agent's
edit with a stale copy is the worst available outcome. The phone offers
three ways out and says what each costs: **Overwrite** (theirs is lost),
**Reload** (yours is lost), **Cancel** (keep editing, decide later).
compares it against the file as it is now and exits distinctly if it differs;
the server answers **409**. Agents edit files while people read them; this is
the common case, not the exotic one, and silently overwriting an agent's edit
with a stale copy is the worst available outcome. The phone offers three ways
out and says what each costs: **Overwrite** (theirs is lost), **Reload**
(yours is lost), **Cancel** (keep editing).
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1"
"$1.ai-app-tmp" && mv -f -- "$1.ai-app-tmp" "$1"`, with the bytes on
stdin. A temp file and a rename, so a connection dropped mid-write leaves
the old file whole rather than a truncated one; `chmod --reference` keeps
the mode, which a fresh file would otherwise lose (an executable script
would stop being one). What this trades away: the inode changes, so a hard
link elsewhere stops being the same file. Accepted; editors do the same.
The check-then-write is not atomic against a writer landing between the
two -- a window of microseconds on the same machine -- and that is accepted
too, and noted at the script.
The response carries the new size, mtime and sha256, so the editor's
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1" … && mv -f`,
with the bytes on stdin: a temp file and a rename, so a connection dropped
mid-write leaves the old file whole rather than truncated, and
`chmod --reference` keeps the mode a fresh file would lose (an executable
script would stop being one). What this trades away is the inode, so a hard
link elsewhere stops being the same file — accepted; editors do the same. The
check-then-write is not atomic against a writer landing between the two, a
window of microseconds on the same machine; accepted, and noted at the
script. The response carries the new size, mtime and sha256, so the editor's
precondition is fresh without a second read.
### 7. Create refuses to overwrite
`POST /setups/{id}/file {path}` runs under `set -C` (noclobber) and
`: > "$1"`, so a name that exists fails with the shell's own message rather
than truncating somebody's file. `POST /setups/{id}/dir {path}` is `mkdir
--` with the same property. The modal names one thing in the current
directory and has a switch for "directory"; a created file opens straight
into edit mode, because an empty file is not something to look at.
`POST /setups/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a
name that exists fails with the shell's own message rather than truncating
somebody's file; `POST /setups/{id}/dir` is `mkdir --` with the same
property. The modal names one thing in the current directory and has a switch
for "directory"; a created file opens straight into edit mode, because an
empty file is not something to look at.
Rejected: create-with-content in one request. The editor is the place
content is typed, and a modal with a text area is a second editor.
Rejected: create-with-content in one request. The editor is where content is
typed, and a modal with a text area is a second editor.
### 8. The viewer is a list of lines, coloured once
The file is scanned once, off the main thread, by `scan` in
`Highlighter.kt` with `rulesOf(language)`; the spans are bucketed per line
in one pass, and each line's `AnnotatedString` is built when that line is
composed. A `LazyColumn` of lines, not one `Text`: text layout is linear
in the text, and a 20,000-line file in one `Text` measures all of it to
draw a screenful. Lines are drawn with `softWrap = false` inside one
shared `horizontalScroll` state, so the whole file scrolls sideways as a
block and a line never wraps.
The file is scanned once, **off the main thread**, by `scan` in
`Highlighter.kt`; the spans are bucketed per line in one pass and each line's
`AnnotatedString` is built when that line is composed. A `LazyColumn` of
lines, not one `Text`: text layout is linear in the text, so a 20,000-line
file in one `Text` measures all of it to draw a screenful.
Line numbers are a gutter in each row, right-aligned, with the gutter
width taken from the digit count of the line count in the same monospace
style -- so a 9-line file and a 12,000-line file each get exactly the
width they need and nothing is measured by hand. Because nothing wraps, a
logical line is one visual line, and the gutter cannot drift from the text
it numbers. Gutter numbers take `onSurfaceVariant`; the text takes the
**Every row is given the same width**, and that is what makes the shared
horizontal scroll work. `horizontalScroll` is a node per row, and each one
coerces the shared offset into *its own* range — content width less viewport
— so with rows at their natural widths a short line's range is zero and it
does not move at all while the long line beside it does. Each row also writes
`maxValue` as it measures, so how far the file could be dragged was decided
by whichever row measured last and changed as the list scrolled. The width is
the longest line in columns times one character's advance, which is
arithmetic rather than twenty thousand measurements because the face is
monospace. 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 — and 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
as "it seems to affect different rows differently", which is precisely what a
per-row range looks like.
**The stretch at the ends is one effect too**, shared by every row and
rendered once on the box around the list — `horizontalScroll` makes its own
per node otherwise, so only the line under the finger bent while the rest of
the file sat still. It cannot be seen from this VM: the emulator's
screenshots come back with no stretch in them at all, for any scrollable, so
that one is checked on the phone.
**The numbers sit outside that box**, so they neither travel with the text
nor bend with it. The rows leave a spacer where the numbers go and a
`SubcomposeLayout` beside the list draws them. That is the one arrangement
that keeps them level: which numbers exist *and* where each goes both come
from the list's own `layoutInfo`, read in the measure block, and
subcomposition happens during measurement — so it composes from the answer
the list has just produced rather than one it read a frame ago. A column
translated by the scroll position could not, since the translation would be
current while the set of numbers was a composition behind, and during a fling
the numbers would slide against their lines. Checked at about 1kHz through a
fling: 23,520 row observations over 552 frames, every one with its number at
exactly its own top. A consequence worth having: the numbers are outside the
`SelectionContainer`, so copying part of a file gives the code rather than
the code with a number in front of every line.
The gutter is right-aligned, its width taken from the digit count of the line
count in the same monospace style, so a 9-line file and a 12,000-line file
each get exactly the width they need and nothing is measured by hand. Because
nothing wraps, a logical line is one visual line and the gutter cannot drift
from the text it numbers. Numbers take `onSurfaceVariant`; the text takes the
scanner's palette on `rawSurface`, the surface every verbatim thing in the
app already sits on.
The language comes from the file's extension through the same table
`fenceLanguage` reads (`FENCE_LANGUAGES` already keys on `kt`, `rs`,
`py`, …). One function, `fileLanguage(name)`, takes the part after the
last dot and asks that table; it is one table, not two, so a language
added for fences is added for files. A file with no entry is drawn plain,
for the reason the table's comment gives.
Selection: the lines sit inside one `SelectionContainer`, as the
transcript does, so a selection can run across lines.
`fenceLanguage` reads — one table, not two, so a language added for fences is
added for files. A file with no entry is drawn plain.
### 9. The editor is the legacy text field with a highlighting transformation
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the
same monospace style, inside the same horizontal scroll so it does not
wrap, with a `VisualTransformation` that returns the text unchanged and
the scanner's spans as styles (`OffsetMapping.Identity`, since no
character moves). This is the one Compose API that colours a field's text
without replacing the field; the newer `TextFieldState` API has no hook
for styles. The gutter is one `Text` of `1\n2\n…` in the same style beside
the field, aligned for the same reason as the viewer: no wrap, one line
each.
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the same
monospace style, inside the same horizontal scroll so it does not wrap, with
a `VisualTransformation` that returns the text unchanged and the scanner's
spans as styles (`OffsetMapping.Identity`, since no character moves). This is
the one Compose API that colours a field's text without replacing the field;
the newer `TextFieldState` API has no hook for styles. The gutter is one
`Text` of `1\n2\n…` beside the field, aligned for the same reason as the
viewer.
Save is a glyph in the header, **disabled** until the text differs from
what was loaded (never hidden -- a control that comes and goes makes its
own absence the signal), and a `GlyphSpinner` while the write is out.
Back with unsaved changes asks; the question says the edits will be lost.
The keyboard: the explorer draws over the session, which deliberately has
no `imePadding` (see `SessionScreen`'s layout note), so the explorer's own
box adds it.
Re-scanning on every keystroke is the cost to watch. For a file under
`FILE_LIMIT` it is expected to be a few milliseconds (the scanner replaced
a library that took 174ms on 200 lines; ours has not been measured on a
1 MiB file). Measure before deciding whether edit mode needs a size below
which highlighting is on -- see "Numbers to measure".
Save is a glyph in the header, **disabled** until the text differs from what
was loaded never hidden, since a control that comes and goes makes its own
absence the signal. Back with unsaved changes asks, and says the edits will
be lost. The explorer draws over the session, which deliberately has no
`imePadding`, so the explorer's own box adds it.
### 10. The explorer draws over the session, and back closes it first
@@ -214,240 +214,138 @@ which highlighting is on -- see "Numbers to measure".
`FilesScreen` is composed **on top of** the session in the same `Box`, and
the session stays composed under it: its event stream keeps flowing, its
scroll position and draft stay where they were, and returning from a file
costs nothing. Back -- the button, the platform gesture and `swipeBack` --
clears `files` when it is set and goes to the list otherwise. Inside the
explorer the same back steps one level: editor → viewer (with the unsaved
question), viewer → listing, listing → parent directory it came from, and
only from the starting directory does it close. "Back returns; it does not
exit."
costs nothing. Back the button and the platform gesture — clears `files`
when set and goes to the list otherwise. Inside the explorer the same back
steps one level: editor → viewer (with the unsaved question) → listing →
parent directory, and only from the starting directory does it close. "Back
returns; it does not exit."
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from
a leaf screen goes to Main today, and a session disposed and re-created on
each return refetches its transcript over the tunnel -- exactly the flip
between "what did it change" and "what is it saying" this feature is for.
The image viewer already made the same choice for the same reason.
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a
leaf screen goes to Main today, and a session disposed and re-created on each
return refetches its transcript over the tunnel exactly the flip between
"what did it change" and "what is it saying" this feature is for. The image
viewer already made the same choice for the same reason.
### 11. The listing is drawn as it came, sorted at display time
Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and
whether the entry is a symlink (with the kind being the *target's*, from
`find -printf '%Y'`, so a link to a directory navigates). Sorted on the
phone, stably: directories first, then case-insensitive name. Dotfiles are
shown -- in a repository they are half of what matters. A row is the
glyph, the name, and the size for a file; tapping a directory descends,
tapping a file opens it. Each directory's entries are kept for as long as
the explorer is open, keyed by path, so returning to one does not refetch
it; the header's refresh glyph refetches the current one on purpose, and a
create refetches the directory it created into, since that is what the
operation changed.
whether the entry is a symlink with the kind being the *target's*, from
`find -printf '%Y'`, so a link to a directory navigates. Sorted on the phone,
stably: directories first, then case-insensitive name. Dotfiles are shown; in
a repository they are half of what matters. Each directory's entries are kept
for as long as the explorer is open, keyed by path, so returning to one does
not refetch it; the header's refresh glyph refetches the current one on
purpose, and a create refetches the directory it created into, since that is
what the operation changed.
An empty directory says "Nothing here". A listing that failed says why,
in the machine's words, where the rows would be -- never an empty list.
An empty directory says "Nothing here". A listing that failed says why, in
the machine's words, where the rows would be never an empty list.
Entries are separated by `\0` in the script's output and by `\t` within a
line (`find -printf '%y\t%Y\t%s\t%T@\t%f\0'`), so a filename with a
newline or a tab in it survives; `parse_entries` is a unit test with
exactly those names in it.
line, so a filename with a newline or a tab in it survives; `parse_entries`
is a unit test with exactly those names in it.
### 12. Icons
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script
rerun and its output committed (it needs network):
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script rerun
and its output committed: `md-folder` U+F024B (the header button and
directory rows), `md-plus` U+F0415, `md-pencil` U+F03EB,
`md-content_save` U+F0193, `md-file_outline` U+F0224. The folder and the plus
are the same codepoints dev-updater uses and must not drift from it, as the
cog and the refresh arrow already must not. All five were looked up in Nerd
Fonts' own `glyphnames.json` rather than copied from memory, which is the
check that a codepoint means the glyph its comment names.
- `md-folder` U+F024B -- the header button, and directory rows. The same
codepoint dev-updater uses, and it must not drift from it, as the cog
and the refresh arrow already must not.
- `md-plus` U+F0415 -- create. Also dev-updater's.
- `md-pencil` -- edit.
- `md-content_save` -- save.
- `md-file_outline` -- file rows.
**The folder button sits between the usage chart and the cog**, so the header
reads widest scope to narrowest and the cog stays at the end where every
other screen keeps it. Asked for in that order by Iris on 2026-09-03.
The last three are verified against the Nerd Fonts cheat sheet when they
are added, not copied from memory.
### 13. The render report moved, and the benches moved with it
### 13. The render report moves, and the benches move with it
The speedometer goes. The report it copies is the standard measurement
`transcript-bench.sh` and `stream-bench.sh` read from logcat, so it stays
reachable: a "Copy render timings" row in `SessionSettingsDialog`, which
is where the session's other about-the-session controls already are.
**No script that drives the UI taps by coordinate, and moving this
button is where that rule gets enforced** (Bryan, 2026-09-03). Both bench
scripts press the button today as `ui-trace record --do 'tap 723 205'`, a
position measured once by hand. Anything that moves the header -- this
change, a font size, a density, another emulator -- makes that tap land on
whatever now sits there, and the script then reports a number that was
never measured, which reads exactly like a result. A control is found by
the name it already carries for assistive technology (`GlyphButton`'s
`label`, a row's text) and pressed at the bounds the screen reports at
that moment.
That belongs in the tool, not in each script: `ui-trace` in
`~/repos/emulator-tools` gains a tap-by-label action (`tap 'Session
settings'`, resolving the element's box from the same uiautomator tree
`elements` already reads, at the moment of the gesture), and both benches
move onto it in the same commit as the button -- cog, then "Copy render
timings" -- so the measurement is never unavailable and never wrong
quietly. `grep -n "tap [0-9]" app/*.sh` is the check that no coordinate
tap is left, and it goes in the emulator-tools README beside the action.
Once the action exists, this rule applies to every script that presses
something on an Android screen, not only these two.
The speedometer went; the report is a "Copy render timings" row in
`SessionSettingsDialog`, where the session's other about-the-session controls
already are. **Moving it is where the no-coordinate-taps rule got enforced**
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
## HTTP surface
Added to the table in `routes.rs`'s module doc:
```text
GET /setups/{id}/dir?path=P entries of directory P, and P resolved
GET /setups/{id}/file?path=P content of file P, or why not
PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
(409 when the file no longer matches ifSha256)
POST /setups/{id}/file {path} create empty; refused if it exists
POST /setups/{id}/dir {path} create; refused if it exists
```
Bodies use `deny_unknown_fields` like every other body here. Paths in the
query string are URL-encoded by `Api.kt`'s existing helper.
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
like every other body here; paths in the query string are URL-encoded by
`Api.kt`'s existing helper.
```json
GET dir -> {"path":"/home/bob/repos/ai-app",
"entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false},
{"name":"README.md","kind":"file","size":1234,"modified":1756900000,"link":false}]}
"entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false}]}
GET file -> {"path":"/…/x.rs","kind":"text","size":1234,"modified":,"sha256":"…","content":"…"}
| {"path":"/…/a.png","kind":"binary","size":45678,"modified":}
| {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":}
PUT file -> {"size":1240,"modified":,"sha256":"…"}
```
Errors: `BadRequest` with the machine's message for a path that is not
there, not allowed or not absolute; the existing 409 variant for the
precondition; `Internal` only for the server's own faults. The message is
what the phone shows, in place, so it is written to be read there.
Errors: `BadRequest` with the machine's message for a path that is not there,
not allowed or not absolute; 409 for the precondition; `Internal` only for
the server's own faults. The message is what the phone shows, in place, so it
is written to be read there.
## Server work (`server/src/files.rs`)
## What the measurements said (2026-09-04)
One module, with the same shape as `setups.rs`: the scripts as constants,
one `pub async fn` per operation taking `&Transport`, and the parsing as
pure functions with tests.
Taken on the emulator in a **debug** build, which runs Compose at a fraction
of release speed and renders in software — so these rank correctly against
each other and are pessimistic in absolute terms. Generated Rust, through the
app's own render report.
1. `Transport::capture_with_input(launch, stdin)` -- `capture` with bytes
on stdin. `ship_attachment` in `routes.rs` builds this by hand today
(an `ssh::command`, a `File` on stdin, `output().await`); it moves onto
the new helper in the same change, so there is one description of
"run this there with this on stdin" rather than two.
2. `list(transport, path) -> Listing`: `cd -- "$1" && pwd -P && find .
-mindepth 1 -maxdepth 1 -printf '%y\t%Y\t%s\t%T@\t%f\0'`. First line is
the resolved path; the rest is entries. `parse_entries` tested with
names containing a tab, a newline, a leading dash and a `'`.
3. `read(transport, path) -> Read`: `stat -c '%s %Y' -- "$1"`, refuse
above `FILE_LIMIT` before `cat` so a 2 GB log never crosses the
tunnel, then `sha256sum -- "$1"` and `cat -- "$1"`, header lines then
bytes; the server splits at the header and decides `text`/`binary` by
`String::from_utf8`.
4. `write(transport, path, expected_sha256, bytes) -> Written`: the
script in decision 6, with a distinct exit code for the precondition
(`exit 3`) that the route maps to 409; anything else is the machine's
stderr.
5. `create_file`, `create_dir`: decision 7.
6. Routes in `routes.rs`, each resolving the setup with `setup_by_id` and
`Transport::for_setup` as `set_cwd` does. The path check (absolute or
`~`) is one function shared with `set_cwd`, which has it inline today.
7. Tests: the parsers; the quoting (a path that tries to close the quote
ends up as one absurd argument -- `ssh.rs` has the pattern); and an
integration test running each script through `Transport::Here`
against a `tempfile` tree, which is cheap because `sh` is there
wherever `cargo test` runs. The precondition test writes the file
between the read and the write and asserts the 409 path.
8. PLAN.md: the Security paragraph from decision 3, and an "Explorer"
section pointing here. AGENTS.md: the layout bullet for `files.rs`.
| file | lines | scan + cut | scan per keystroke | worst frame record |
|--------|--------|------------|--------------------|--------------------|
| 32 kB | 917 | 11ms | 10ms | 183ms |
| 128 kB | 3,633 | -- | 40ms | 2,027ms |
| 1 MB | 28,660 | 460ms | -- | -- |
## App work
Three things followed.
1. `Api.kt`: `fetchDir`, `fetchFile`, `writeFile`, `createFile`,
`createDir`, and the three data classes (`DirEntry`, `FileContent`
as a sealed class with the four kinds, `Written`).
2. `NerdIcons.kt` + `build-icon-font.sh`: decision 12.
3. `Languages.kt` (or `CodeFence.kt`, wherever `FENCE_LANGUAGES` sits):
`fileLanguage(name)`.
4. `FileLines.kt`: the pure half of the viewer -- spans bucketed per line,
`lineOf(index) -> AnnotatedString` -- so it has a JVM unit test beside
`HighlighterTest`, the app's one existing test suite, covering a block
comment that spans lines and a file with no trailing newline.
5. `FilesScreen.kt`: the listing, the navigation stack, the per-directory
cache, the create dialog (modelled on `AddSetupDialog`: fields, a busy
state, the failure shown inside the dialog beside the button that
caused it), and the header. `LoadState` for the listing.
6. `FileViewer.kt`: decision 8. `FileEditor.kt`: decision 9, including
the conflict dialog.
7. `AppRoot.kt`: decision 10. `SessionScreen.kt`: the folder glyph where
the speedometer was, `onFiles(setup, cwd)` out to the root.
8. `SessionSettingsDialog.kt`: the render-report row. In
`~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the
two bench scripts onto it, with no coordinate tap left in `app/*.sh`.
**The viewer's scan had to leave the main thread.** Decision 8 said "off the
main thread" and the first version did it in a `remember` inside the
composition, which is not that: 460ms of frozen screen at the size the server
is willing to send, long enough that the accessibility tree cannot be read —
which is exactly what "the app has stopped" looks like from outside.
## Testing
**`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a
1 MiB file, tap to text on screen, was **2.4s** against the sandbox — 1.2s of
which is that server's deliberate `--delay`, and 460ms the scan. The transfer
is not what dominates, so the route gains nothing from streaming.
- **Server**: `./run-tests.sh`, `cargo clippy --all-targets`, `cargo fmt`.
- **Local transport, by hand**: `./ui-sandbox.sh api
"/setups/<id>/dir?path=~"` against the sandbox, whose `$HOME` is a
throwaway tree it is fine to write into. The sandbox gets a small
fixture directory with the states worth seeing: an empty directory, a
file with a tab in its name, a binary file, one over `FILE_LIMIT`, an
unreadable one (`chmod 000`), a symlink to a directory, and a source
file in each of a few languages.
- **Remote transport**: the ssh-to-this-VM recipe in AGENTS.md ("How to
test SSH here"). The point of the exercise is the quoting and the
stdin path: write a file whose name has a `'` in it, and read it back.
- **Phone**: `ui-trace`, not screenshots, for the things this feature is
made of -- that the gutter's number and its line share a baseline at
the first and the last row, that a long line's row is wider than the
viewport and does not grow the row height, that the editor's gutter
stays put while the text scrolls sideways. Screenshots for colour and
contrast on `rawSurface`.
- **States to produce on purpose**, since the default state is the one
everybody looks at: a directory that fails to list (permission),
an unreachable machine (a setup pointing at a dead address), `binary`,
`tooBig`, the 409 conflict (edit the file with `sed -i` on the machine
between opening and saving), creating a name that exists, back with
unsaved edits, and the keyboard up over the editor.
**Edit mode needed a cap, and not the one that was expected.** The plan
expected to be deciding a size below which highlighting stays on. That is not
the cost that matters: highlighting 128 kB costs 40ms a keystroke, which is
survivable, while laying the same text out in one `BasicTextField` costs two
seconds — characters typed into it were dropped, and a 1 MiB file stopped the
app responding altogether. Since every arrangement of a single text field
pays that, switching highlighting off would have saved nothing. So
`EDIT_LIMIT` is **32 kB**, the largest size measured as usable, and above it
the pencil is disabled with the reason said 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.
## Numbers to measure, before deciding
- Scan time for a 1 MiB source file on the emulator, and on the phone
through the render report. That decides whether `FILE_LIMIT` is right
and whether edit mode highlights every keystroke or only below a size.
- Time to first line for a 1 MiB file over the tunnel: the read, the
transfer, the scan, the first composition. If the transfer dominates,
the route gains nothing from streaming; if the scan does, it moves to
a worker with the plain text drawn first.
- The `BasicTextField` at 20,000 lines: whether typing stays responsive.
If not, edit mode gets a lower cap than the viewer, stated in the
editor rather than discovered by a stuck keyboard.
## Order of work
Each step leaves the app working and is one commit.
1. Server: `files.rs` with `list` and `read`, routes, tests. Half a day.
2. App: icons, `Api.kt`, `FilesScreen` listing, `FileViewer`, the root
and session wiring, the render-report move with the benches. A day.
3. Server: `write`, `create_file`, `create_dir`, the stdin helper and
`ship_attachment` onto it. Half a day.
4. App: `FileEditor`, the create dialog, the conflict dialog. Half a day.
5. Measurements above, the sandbox fixture, PLAN.md and AGENTS.md. Half a
day.
Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine,
because it is a `LazyColumn` of lines rather than one text object. That
difference is the whole of decision 8.
## Later, deliberately not now
- Delete, rename and move. Destructive controls belong here eventually,
shown and confirmed rather than hidden, but none of them is needed to
read or change a file.
- Delete, rename and move. Destructive controls belong here eventually, shown
and confirmed rather than hidden, but none is needed to read or change a
file.
- Images in the viewer, through the existing `SessionImageViewer`.
- Following an agent's edits live: a file open in the viewer refreshing
when a `Write`/`Edit` tool call on the same path lands in the
transcript. The transcript already knows the path.
- Following an agent's edits live: a file open in the viewer refreshing when
a `Write`/`Edit` tool call on the same path lands in the transcript. The
transcript already knows the path.
- Remembering the last directory per session.
- Uploading from the phone into a directory. Attachments already do the
upload half; this would be the same route with a chosen destination.
upload half.
- Search within a file, and find-in-files.
- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The viewer
already draws a file as rows and stays fast on a megabyte; an editor built
the same way — a field per line, or a field over the lines on screen —
would not pay Compose's cost of laying out one enormous text. It is a good
deal more than this feature needed, and 32 kB covers the config files,
notes and ordinary source files anybody edits from a phone.
+277
View File
@@ -0,0 +1,277 @@
# iris: notable public API changes
For Iris to read on her own time. Each entry is a change to iris's public
surface that a widget author or app author would notice: a trait method
added, removed or re-shaped; a type that callers construct differently; a
capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-05: `transcript_ui::build_tree` (RUST.md's E4)
`transcript_ui::build` claimed the whole window (`ui_state.set_root(tree)`)
as its last step, which is right for a window that *is* the transcript
screen (the winit example, an eventual Android cdylib) and wrong for the
desktop app, which puts a session list beside it. `build_tree` is `build`
minus that last step: it returns `(TranscriptScreen, StrongWidget)` instead
of just `TranscriptScreen`, and the caller decides where the tree goes —
into `ui_state.set_root`, or into a `WidgetPtr` alongside something else
(`iris/desktop-app`'s `rebuild_transcript`). `build` is now one line calling
`build_tree` and doing the `set_root` itself, so existing callers are
unaffected.
```rust
// before, and still available, for a caller that wants to *be* the window:
let screen = transcript_ui::build(rsc, &mut ui_state, rows);
// new, for a caller embedding the screen beside something else:
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
some_widget_ptr(rsc).set(tree);
```
## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
New public type, `iris::sense::DragArbiter`. Why: a widget author who
registers both a list-level pan and a row-level drag-to-select on the same
touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s
`run_sensors` always gives the innermost layer first refusal, so the inner
one wins every frame it is pressed, not just the frame the press started
(this is exactly what left transcript-ui's touch-drag panning unreachable
until now). `DragArbiter` is one small state machine, one instance per
gesture surface (a whole list, not per row), that a caller drives with its
own `press_start`/`update`/`release` calls and a caller-supplied `Instant`
(so it is unit-testable without a real clock or a render harness). It
decides the way Android itself does: an ordinary vertical drag pans
immediately; a stationary press held `LONG_PRESS` (500ms) starts a
selection, which any further drag then extends; a horizontal drag while
something is already selected extends it immediately, skipping the wait.
```rust
// One per list, held alongside whatever state coordinates the rows:
let mut arbiter = DragArbiter::new();
// On press-down:
arbiter.press_start(pos, Instant::now(), already_selected);
// Every frame the button/finger stays down:
match arbiter.update(pos, Instant::now()) {
DragOutcome::Pan(dy) => list.scroll(-dy),
DragOutcome::SelectStart => selection.begin(...),
DragOutcome::SelectExtend => selection.extend(...),
DragOutcome::Undecided => {}
}
// On release:
arbiter.release();
```
`transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is
the reference caller: every row's `CursorSense::click_or_drag() |
CursorSense::unclick()` handler routes through one `Selection`-owned
arbiter instead of calling `begin`/`extend` directly, so a drag that starts
on a row's own rendered text now pans the list correctly instead of
always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s
`drag_arbiter_tests` module.
## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5)
A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size,
family, ...) for its whole string, applied via `push_default` into parley's
ranged builder. `SpanStyle` is a second, optional layer: a byte range plus
whichever of colour/family/font size/bold/italic/underline it overrides,
pushed with parley's own `push(property, range)` instead. Why: a transcript
row's markdown (a heading, **bold**, `inline code`, a link) all inside one
wrapped paragraph needs each to carry its own look while the paragraph
still wraps and selects as a single buffer — the thing `masonry`'s
`TextArea` cannot do (`StyleSet` is one style for the whole editor,
`text_area.rs:43-44`'s `// TODO: RichTextInput`), and the reason this
existed at all.
```rust
let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0);
wtext(text)
.spans(spans) // new: TextBuilder::spans, on both Text and TextEdit
.editable(EditMode::MultiLine)
.add(rsc);
```
Two things a widget author should know before reaching for it:
- **Call `.spans()` before or after `.editable()`, both work** — the field
lives on `TextBuilder` itself, not either output type, and both
`TextOutput::run` and `TextEditOutput::run` apply it to the buffer via
`TextBuffer::set_spans`. **These two call sites are a pair**: adding a
third `TextBuilderOutput` impl without also calling `set_spans` there
reproduces the exact bug this box shipped once already (spans silently
dropped for `TextEdit`, found only by screenshotting, not by any test —
`markdown.rs`'s own unit tests check string/range logic, which is
correct in isolation and proves nothing about whether the render path
ever sees it).
- **Colour is now per-glyph, not per-buffer.** `PlacedGlyph` gained a
`color: UiColor` field (from parley's own per-run `Style::brush`), and
`Painter::glyphs` draws each glyph in its own colour instead of
`RenderedText::color` uniformly. `RenderedText::color` still exists (the
buffer's *base* colour, for a caller that wants it as a whole, e.g. to
tint a cursor) but no longer drives what a glyph actually renders as.
## 2026-09-05: accessibility names via AccessKit (RUST.md's I4)
`.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree)
is now load-bearing: it's the one thing that puts a widget in the AccessKit
tree `iris_core::ui::access::AccessTree` builds and both backends push
out. A widget author who wants a control to be findable by name (and
tappable by name, through `ui-trace`/a real screen reader) calls `.label()`
on it; nothing else is required, and a widget nobody labels is invisible
to this system at zero cost, not just zero UI.
```rust
let button = rect(Color::LIME)
.on(CursorSense::click(), move |_, rsc| { ... })
.label("Add task"); // now findable by uiautomator/AccessKit as "Add task"
```
Two new things a widget author might touch directly:
- **`Widget::access_role(&self) -> accesskit::Role`**, default `Unknown`.
Override it if your widget has a real platform equivalent —
`TextEdit` now returns `TextInput`/`MultilineTextInput` by `EditMode`.
Only consulted for a widget that also has a `.label()`; an unlabelled
widget's `access_role` is never called.
- **`Widgets::named() -> impl Iterator<Item = WidgetId>`** — every widget
with an explicit label, for anything else that wants to walk the same
set `AccessTree` does.
Nothing about `Painter`, `draw`, or the layout/move machinery changed —
this sits entirely beside them, reading `resolved_region`'s output rather
than participating in producing it.
## 2026-09-05: `List`, a virtualised bottom-anchored list (RUST.md's I3)
A new widget, `iris::widget::List` (`iris/src/widget/list.rs` -- read its
module doc first), for the transcript's kind of screen: variable-height
rows, keyed by a `u64`, composed only while visible, moved rather than
re-laid-out on scroll, a scroll anchor that survives a row inserted above
it, "more" sentinels at each end, and "hold the edge nearest the tap" when
a row's height changes (`note_tap`, resolved in the layout pass).
```rust
let mut list = List::new(Axis::Y);
list.push_back(ListRow::new(key, row_widget)); // O(1)
list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected
list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge
list.note_tap(viewport_y); // before mutating a row's height
let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible
```
Built entirely out of existing primitives (`Painter::widget`/`widget_within`/
`reposition`/`draw_twice`, and `draw_inner`'s own old-children diffing) --
no new mechanism was added to the render core for it. One correctness
lesson worth reading even for other widgets: a row that fills whatever
region it is offered (`Rect`, `is_size_independent`) cannot be measured at
a throwaway oversized region and then merely `reposition`ed into place --
`reposition` only ever writes an offset, never a size, so the oversized
primitive stays oversized. `List` fixes this by caching each row's real
height once measured and placing an already-known row directly at its
exact box; see `list.rs`'s `place` for the full reasoning and
`a_fill_shaped_background_is_not_left_oversized` for the regression test.
## 2026-09-05: a second backend (android-view), and what moved to make room for it
RUST.md's I2. Three changes a widget or app author would notice, all in
service of the same thing: `default` (winit) and the new `android`
(android-view) backends sharing what does not depend on windowing.
- **`Selector`/`Selectable`'s bound changed from `Rsc::State:
HasDefaultUiState` to `Rsc::State: FocusHost`** (new trait, `attr.rs`).
`HasDefaultUiState` still exists and still works — `default/attr.rs` now
implements `FocusHost` for anything that has it — so a winit app's
existing code is unaffected. An Android app implements `FocusHost` via
`HasAndroidUiState` instead. Affects only an app that referenced
`HasDefaultUiState` directly at a `Selectable`/`Selector` call site
rather than through `.attr::<Selectable>(())`, which nothing in-tree
does.
- **`Tasks::init` takes `Arc<dyn RequestRedraw>` instead of
`Arc<winit::window::Window>`.** `RequestRedraw` (`task.rs`) is one method,
`fn request_redraw(&self)`; `winit::window::Window` implements it
(`default/render.rs`), so `Tasks::init(window)` at a call site is
unchanged by inference. Only matters if something constructed a `Tasks`
directly rather than through `DefaultRsc`/`AndroidRsc`.
- **`TextEdit::apply_event`/`TextInputResult` are `#[cfg(not(target_os =
"android"))]`** — they take a `winit::event::KeyEvent`, which does not
exist on Android; `android/input.rs` drives the same primitives
(`backspace`/`delete`/`motion`/`insert`, all still unconditional) from
`ndk::event::Keycode` directly instead. New unconditional getters on the
way: `TextEdit::text()`/`selection_range()`/`caret()`, and
`TextEditCtx::delete_byte_range`/`set_cursor_byte` — the primitives
`android/ime.rs`'s `InputConnection` bridge needed and that were not
previously exposed publicly.
## 2026-09-04: `Widget::draw` reports the size it used; `desired_width`/`desired_height` are gone
A widget used to implement three methods (`draw`, `desired_width`,
`desired_height`); it now implements one, `fn draw(&mut self, painter: &mut
Painter) -> Size`, which draws into `painter.region()` and returns how much
of it was used. Why: the two extra methods routinely re-simulated what
`draw` was about to do anyway (`Span::desired_ortho` copied its own draw
loop to get cross-axis sizing right) — one visit per widget per frame
instead of up to three. A container that needs a child's size before
placing it (alignment, centering) draws the child once at a provisional
region, reads the returned `Size`, and calls the new `Painter::reposition`
to move it into its final spot — an O(1) offset write, not a second draw. A
widget whose drawn output never depends on the size it's given (a
fixed-size `Rect`, a decoded `Image`) overrides the new `fn
is_size_independent(&self) -> bool { false }` to `true`, which skips
redrawing it when only its offered region changes shape.
```rust
// before
fn draw(&mut self, painter: &mut Painter) { /* ... */ }
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
// after
fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
```
`SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
design, the move-offset mechanism this shipped alongside, and the file
list.
## 2026-09-04: texture pipeline rebuilt off the binding array
`Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all
changed shape. Why: the old pipeline bound every texture ever drawn in one
`binding_array<texture_2d<f32>>` and asked every device, unconditionally,
for `VK_EXT_descriptor_indexing` — a real share of Android GPUs lack it,
and it failed outright on the Android emulator's software Vulkan. See
TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04".
- **`UiRenderNode::new` drops its `limits: UiLimits` parameter, and
`UiLimits` is gone.** Before: `UiRenderNode::new(&device, &queue,
&config, UiLimits::default())`. After: `UiRenderNode::new(&device,
&queue, &config)`. Nothing replaces it — there are no more
binding-array limits to size.
- **`src/default/render.rs`'s device request asks for no features and no
binding-array limits.** Before: `required_features:
Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`
plus two `max_binding_array_*` limits. After: `Features::empty()` (the
`DeviceDescriptor` default) and only `max_buffer_size` set, which was
never about the binding array.
- **`TextureHandle` has no `primitive()` method any more**; a caller
outside `iris` shouldn't have been calling it (it fed the old renderer's
internals), but if something did: use `image_index()` for a standalone
image's bind-group index. There is no equivalent for a page — a page has
no bind group of its own now, see below.
- **`GlyphPrimitive` has no public constructor from a struct literal.**
Before: `GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color,
flags }`. After: `GlyphPrimitive::new(uv_min, uv_max, layer, color,
flags)` — one `layer` (the shared atlas array's layer) instead of a
`view_idx`/`sampler_idx` pair, since a page is now a layer of one array
texture rather than its own bound texture.
- **A widget author drawing images is unaffected**: `Painter::texture`/
`texture_at`/`texture_within` and `Textures::add` keep their signatures.
What changed underneath is that each standalone image now gets its own
`wgpu::BindGroup` and draw call instead of a slot in the shared array —
invisible from the widget API, visible only in `UiRenderNode`'s internals
and in `iris`'s device requirements.
+254
View File
@@ -0,0 +1,254 @@
# iris: known problems and things still to build
Iris's own list for the library, recorded 2026-09-04 in her words where it
matters, so the agents working through RUST.md pick these up in a sensible
order rather than rediscovering them. Each item says where it sits in the
order and what "done" looks like. Tick and date them in place.
## Fix
- [x] **Input does not fall through by input type (2026-09-04).**
`SensorUi::run_sensors` (`src/default/sense.rs`) used to set "consumed,
stop checking lower layers" from mere hover — a widget registered for
nothing but `click()` blocked a `Scroll` meant for whatever was behind
it, since "the cursor is over this widget" and "this widget handled the
event" were the same check. Fixed by judging consumption per input
kind: with no button transition and no scroll happening this frame
("momentary" activity), the topmost hovered widget still wins, same as
before; when something momentary *is* happening, only a widget whose
registered senses actually include a matching non-hover one (checked
via a new `TypeEventManager::registered`, which lists what a widget
registered without running anything) consumes it, so a widget with only
`Hovering`/click handlers can no longer block a scroll from reaching a
list underneath. `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.
- [x] **Appending one image to an already-loaded list rebuilds every other
image's bind group (2026-09-05, fixed 2026-09-05).** Found by the
benchmark below: `GpuTextures::update` (`core/src/render/texture.rs`)
triggered `rebuild_image_bind_groups` — a loop over *every live
standalone image*, rebuilding its `BindGroup` — whenever the shared
`masks` or `move_offsets` GPU buffer was resized (`masks_resized ||
moves_resized` in `UiRenderNode::update`, `core/src/render/mod.rs`), and
a widget getting its *first* move-offset slot (LAYOUT.md section 2 —
every widget gets one on first draw) could be exactly what grows that
buffer. So one new message with one new image, appended to a transcript
that already has N images loaded, did not cost O(1): it cost one
`create_image` for the new image plus one `make_image_bind_group` per
*existing* image, because the new widget's own move slot pushed the
arena past its capacity. Measured directly in
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
already-settled ones reported **1,001** bind-group creates for that one
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
**Fix**: `masks`/`move_offsets` never belonged in a standalone image's own
bind group (group 2) in the first place — the group also holds that
image's own texture view, which is the only thing that is genuinely
per-image, so a buffer shared by *everything* forced a rebuild of
*every* group the moment it moved. Gave masks/move_offsets their own
bind group (group 3 in `shader.wgsl` and `UiRenderNode`: `masks_layout`/
`masks_group`), bound once per frame in `UiRenderNode::draw` rather than
once per draw call, instead of duplicating them into every per-image
group. `GpuTextures` and its image bind groups now know nothing about
either buffer — `rebuild_image_bind_groups` is called only from
`grow_array` (the atlas array texture growing, which genuinely does
change what every image's own bind group must reference) — so a
masks/move_offsets resize now touches exactly one bind group, ever,
regardless of how many images are live. Numbers after the fix, same
benchmark and command:
./run-bench.sh images
frame=1 bind_group_creates=1000 (cold load, unchanged)
frame=2 bind_group_creates=0 (was 1000 -- see the item below)
frame=3 bind_group_creates=0
frame=4 bind_group_creates=0
(append one image here)
frame=5 bind_group_creates=1 (was 1001)
frame=6 bind_group_creates=0
`run-headless.sh tabs --shot` still 27266 bytes, byte-for-byte unchanged,
confirming the bind-group restructuring changed nothing about what is
drawn.
- [x] **Bind-group creation takes two frames to reach the steady state, not
one (2026-09-05, closed by the fix above, 2026-09-05).** Same benchmark:
loading 1,000 images cold used to report 1,000 creates on frame 1
(expected — `create_image`, one per new image) *and again* 1,000 on
frame 2, before settling to 0 from frame 3. This was `rebuild_image_bind_groups`
firing a second time for the same masks/move-offsets buffer-growth
reason as the item above, confirming the guess recorded here — the two
were exactly the same root cause measured two different ways. Frame 2
now reports 0 (see the numbers above); not a separate fix.
## Build
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
`benches/` or a script under `iris/`, never in `cargo test`). The
scenario that matters most is a **message list** — chat apps and this
app's transcript alike — stressed with many messages and many images.
One case in particular: **resizing an input box** (typing enough text to
grow it) that pushes a long list of messages above it must stay very
fast and recalculate almost nothing — a move of everything above, not a
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
benchmark is what proves it. Done when the numbers are in this file with
the command, and the input-box case reports draws re-run, not just frame
time.
**Built as two rigs**, chosen per scenario by whether a real `wgpu`
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
most of this runs as an ordinary binary — the same property
`layout_tests.rs` relies on):
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
the file's own header for why (short version — every scenario here
reduces to a *count* `UiRenderState::take_counters` already produces,
which criterion's statistical machinery adds nothing to and which a
new dependency is not worth pulling in for). Covers (a) first-frame
cost of a message list of N wrapped-text rows (one in 20 also carrying
a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost
of scrolling that list, 200 ticks; (c) the input-box case — a
fixed-height field at the bottom of the screen growing by a line 40
times, with the message list above it filling the rest of the screen.
Run: `cd iris && cargo bench --bench message_list` (always release —
`cargo bench` builds the `bench` profile, which is optimized).
- `iris/examples/bench_images.rs` — needs a real device, so it runs
through `iris/run-headless.sh bench_images`, printing
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
mirroring `UiRenderState::take_counters`) each frame. Covers (d): 1,000
image rows, checked both cold (does bind-group creation reach zero
once loaded) and after appending one more image once settled (does
*that* stay cheap) — the second question is what actually matters for
a live transcript and is what turned up the two Fix items above.
- `iris/run-bench.sh [list|images]` runs either or both and is what to
run before/after touching `Scroll`, `Span`, `Sized`, the move-offset
chain, or `GpuTextures`.
**Numbers (2026-09-05, release, `cargo bench`/`run-headless.sh`, this
VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):**
cd iris && cargo bench --bench message_list
(a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0
(a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0
(a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0
(b) scroll, N=100/1000/10000, 200 ticks each:
draws=200 rewrites=0 moves=200 (identical at every N)
per-tick average: 0.0002ms (identical at every N)
(c) input grows 40 lines, N=100/1000/10000 rows above it:
draws=320 rewrites=40 moves=160 (identical at every N)
per-line average: 0.0012-0.0013ms (identical at every N)
cd iris && ./run-bench.sh images (2026-09-05, before the fix)
frame=1 bind_group_creates=1000 (cold load)
frame=2 bind_group_creates=1000 (see Fix item above)
frame=3 bind_group_creates=0
frame=4 bind_group_creates=0
(append one image here)
frame=5 bind_group_creates=1001 (see Fix item above)
frame=6 bind_group_creates=0
cd iris && ./run-bench.sh images (2026-09-05, after the fix)
frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work)
frame=2 bind_group_creates=0
frame=3 bind_group_creates=0
frame=4 bind_group_creates=0
(append one image here)
frame=5 bind_group_creates=1 (one image's own create_image, O(1))
frame=6 bind_group_creates=0
**Reading it**: (a) is real, necessary work — shaping and laying out N
never-before-seen text rows — and scales with N as it must, ~10x cost
per 10x N. (b) and (c) are the pass conditions that matter: both are
**exactly flat across N = 100 to 10,000**, confirming LAYOUT.md's O(1)
move chain holds for both scrolling and for a growing input box pushing
the message list — draws/moves per tick or per line do not grow with
list size, and the per-operation cost (a fraction of a microsecond) is
nowhere near a frame budget. (d)'s cold-load and steady-state halves
behave as designed; its *append* half did not, until the fix above moved
masks/move_offsets out of the per-image bind group — now flat at O(1)
the same way (b) and (c) are.
- **I5's transcript screen (`iris/transcript-ui/`, 2026-09-05) — what it
left, each recorded at the point in the code it would go rather than
silently dropped. See RUST.md's I5 box for the full account of what
*was* built (the screen, `SpanStyle`, cross-row selection, the growing
composer).**
- [ ] **Android integration for this screen does not exist yet.** No
cdylib/Gradle shell the way `iris-android-app` wraps `tabs-ui` (I2),
so `transcript-bench.sh`'s render-number pass condition against the
Compose baseline cannot be run. Needs: real `client-core::ApiClient`/
`event_stream::follow_session_events` wiring against
`app/ui-sandbox.sh --delay` (this crate deliberately fetches nothing
itself, `transcript-ui/src/lib.rs`'s doc), a new cdylib + Gradle
module, then the bench script pointed at it.
- [x] **Touch-drag panning over a row's own rendered text — done,
2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
registration won `core/src/sense.rs::run_sensors`'s per-layer
arbitration on every frame it was pressed, not just the frame the
press started, so a list pan gesture registered on `List` itself never
got a turn while a row was under the finger. Fixed with
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
machine per list deciding pan vs. select the way Android does (a
vertical drag pans immediately; a stationary press held `LONG_PRESS`
(500ms) starts a selection which further drag extends; a horizontal
drag while something is already selected extends immediately) —
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
every row's drag now routes through. 8 new unit tests
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
clean; `run-headless.sh` screenshot byte-identical to before the
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
- [ ] **Row-level accessibility names.** The composer carries
`.label("Message")`; transcript rows do not carry a `.label()` of
their own yet, so `Widgets::named()` (I4) does not include them —
`row.rs`'s `build_text_row` is where one would go, keyed to something
stable per row (its sender + a short excerpt, matching what a screen
reader announcing a chat message would say).
- [ ] **A tappable link and a background chip behind inline code.**
Both need per-range glyph geometry that `TextEditCtx` does not expose
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
private) — see `markdown.rs`'s module doc for the exact shape the fix
would take (the same primitive `TextEdit::draw`'s own selection
highlight already uses internally,
`iris/src/widget/text/edit.rs:99`).
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
is selected in full (`select_all`) the moment the drag leaves it,
rather than "from the click point to whichever edge points away from
the drag" — needs the same private `layout()` access as the item
above. `selection.rs`'s module doc has the exact reasoning.
- [ ] **No syntax highlighting inside a fenced code block.**
`client_core::highlight` exists (built for the file explorer) and
could feed per-token `SpanStyle`s into a code block's span; wiring it
in was not attempted this pass.
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
by something *and also* applies mask B — a mask can reference a parent
mask, the way the move chain references a parent offset. Today masks
are independent regions. Design it beside the move chain (same shape:
a parent index and a bounded walk in the shader); do it when a real
widget needs it, not before.
- [ ] **Positions as a single float per scroll.** Iris raised, and half
rejected, letting a scroll update one float rather than positions:
input handling cares about most elements in a list, so absolute
positions must be computed on the CPU anyway. LAYOUT.md's design
already lands here (GPU walks the chain, CPU resolves on demand for
hit tests). Keep the CPU resolution lazy and per query; do not
materialise every row's absolute position per frame.
- [ ] **Animations, last.** Cosmetic, so after everything above. Must be
**modular — a piece of the library rather than a core part forced into
everything, the same way input is**. Whatever the mechanism, a widget
that does not animate must pay nothing and import nothing for it.
## Reconsider
- [ ] **`WidgetView`.** Iris is unsure of it: what she wants is an easy way
to compose a widget from others (a button is the main case). With
sizing folded into `draw`, composing may be easy enough that `View` is
redundant. Decide after the layout change lands, by writing a button
both ways and keeping the one that is shorter to explain; delete the
other rather than keeping two ways.
+897
View File
@@ -0,0 +1,897 @@
# iris: one `draw` that reports a size
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
any design or code so that it survives a cleared session. **Status: implemented
2026-09-04, against every pass condition in §8** (measured, not assumed — see
that section). Every widget listed in §7 was migrated in one change; none
kept `desired_width`/`desired_height`. Five points needed correction or
refinement beyond what this file originally specified — see "Deviations
found during implementation" below, added right before "For IRIS.md" — read
that section before touching `Aligned`, `Sized`, `MaxSize`, `Scroll`, or the
move-slot lifecycle in `render_state.rs`, since each of those five is a real
bug this file's first draft would have reproduced if implemented literally.
## What Iris asked for
> I don't like that widgets need both a draw and size functions. I'd much
> rather them have a single draw that reports a size, and if it needs to be
> moved then that can be done after the fact efficiently, or resized just
> done after as well. This should be done efficiently like everything else
> tries to do right now.
She added, a few minutes later: "single draw is not a requirement. It
just seems more efficient from what I've heard. Feel free to override any
decision I've made if you can find a genuinely better & still clean
alternative." So the single-draw model is the default to design against,
and the design below may reject it, but only with a written comparison
showing the alternative does less work per frame and is no harder to use.
Standing constraints from RUST.md still apply: no DSL, plain Rust, do as
little processing as possible per frame, but the model must cover every
layout need a real app has (the transcript's virtualised list, wrapped
text whose height depends on width, rows and columns that size to their
children, overlays, masks).
## What exists today
`Widget` (`iris/core/src/widget/mod.rs`) has three methods: `draw(&mut
self, &mut Painter)`, `desired_width(&mut self, &mut SizeCtx) -> Len` and
`desired_height`. A parent asks `SizeCtx::width/height` for a child, which
is memoised per widget id and axis in `Cache.size` keyed on the outer
size, then places the child with `Painter::widget_within(region)`. So a
child is visited twice (sized, then drawn), every widget implements sizing
twice (one per axis), and a widget whose size depends on what it draws
(wrapped text, a laid-out paragraph) does the layout in the size pass and
again in the draw pass unless it caches by hand.
Primitives are already positioned by `UiRegion` values whose scalars have
a `rel` and an `abs` part, resolved against the window in the vertex
shader (`core/src/render/shader.wgsl`), and `Primitives::region_mut`
exists to rewrite one instance's region in place. That is the mechanism a
"move after the fact" can build on.
## What the design must answer
1. **Parent-before-child ordering.** A row has to know each child's width
to place the next one, but under "one draw" the child's size only
exists after it has drawn. The answer is meant to be: the child draws
at a provisional origin, reports its size, and the parent *moves* it.
The move must be O(1) per moved subtree, not O(primitives in the
subtree). One way: every instance carries an index into a small
per-widget offset buffer, so moving a widget writes one entry and the
vertex shader adds it. Other ways may be better; the design should say
what was considered.
2. **Move vs resize are different costs and must be kept apart.** A move
never re-runs `draw`. A resize re-runs `draw` for exactly the widgets
whose size input changed, and a widget whose output does not depend on
its size (an icon, a fixed rect) must be able to say so and be skipped.
3. **Size-dependent content.** Wrapped text is the hard case: its height
is a function of its width. A single `draw` receives the available
size (what `SizeCtx.outer` is today) and reports what it used, so the
two-pass "measure then draw" collapses into one for the common case.
The design must say what happens when a parent wants the child's
height *before* deciding the width it will offer (rare; say whether it
is supported, or is done by drawing twice as an explicit, opt-in cost).
4. **Caching.** Today's `Cache.size` memoises by (id, axis, outer). The
replacement should memoise the whole draw result by (id, available
size) so that an unchanged subtree costs nothing on the next frame,
which is what makes a virtualised list cheap.
5. **Everything currently written against `desired_width`/`desired_height`
moves over in one change**, per the code rules: two names for one
concept is not an intermediate state to leave behind. The widgets are
in `iris/src/widget/` (`ptr`, `mask`, `image`, `rect`, `trait_fns`, and
whatever else is there when the change is made).
## Order relative to the texture work
TEXTURES.md's redesign touches the render core (shader, `GpuTextures`,
`Primitives`, `Painter`'s texture calls). This change touches the widget
trait, `SizeCtx`, `Cache`, `Painter`'s widget calls, and any offset
mechanism the vertex shader needs. They overlap in `Painter` and the
shader, so they are done **in sequence, textures first**, and the layout
design here is written (not implemented) while the texture work is in
progress, then implemented on top of it.
## Design
### 1. The new `Widget` trait
```rust
pub trait Widget: Any {
/// Draw within `painter.region()` (the space the parent offered) and
/// report how much of it was actually used, per axis.
fn draw(&mut self, painter: &mut Painter) -> Size;
/// True if `draw`'s output (both the primitives it writes and the
/// `Size` it returns) is the same for any `painter.region()` of the
/// same *content* -- an icon, a fixed-size rect, an already-decoded
/// image at its natural size. Default `false` (redraw on any change to
/// the offered region) because assuming independence wrongly produces
/// a stale draw; a widget must opt in.
fn is_size_independent(&self) -> bool {
false
}
}
```
No `available` parameter: `Painter` already carries the region the parent
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
size (`output_size()`, `:152`). Passing it again would be the same value
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
deprecated, not kept as a fallback — because a widget that implements both
`draw` and `desired_*` for the same thing is exactly the "two names for one
concept" the code rules call out, and it is what today's `Span::desired_ortho`
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
own comment: "this literally copies draw so that the lengths are correctly
set in the context, which makes this slow and not cool." Folding sizing into
`draw` deletes that duplicate simulation, not just moves it.
**No single-draw alternative was found that does less work per frame.** The
two-method trait was checked against three properties a real screen needs —
a row placing children in sequence, a widget centering on its own content,
and wrapped text — and in every one, `draw` already has to visit the child
to get a size that is *this specific one's* answer, which today's
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
the same layout the draw pass will do again. So the two-method trait is not
"measure once, draw once" in the general case; it is "measure once per axis,
then draw once," i.e. up to three visits per widget per frame, against one
under the design here. The single-draw model is therefore adopted as
proposed, not merely accepted as a preference.
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
**What exists today, and why it is not O(1).** `UiRenderState::mov`
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
its *size* but changes *position* (`draw_inner`, `:85-100`:
`active.region.size() == region.size()` after excluding the exact-match
case). It rewrites every primitive's `region` field via
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
widget's own primitives, then recurses into every child — O(primitives in
the subtree). Both call sites that trigger it today, `Scroll::draw`
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
re-touches every glyph in every visible row, every frame of the drag, and
I3's target is 800 rows on screen.
**Recommendation: a per-widget offset slot forming a parent-linked chain,
resolved in the vertex shader.**
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
add nothing more"). A pure abs-pixel translation, not a general
`UiRegion` remap — sufficient for every existing call site (above).
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
kind of per-instance handle.
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
assigned **when the widget is first drawn** (`draw_inner`, beside
`active.insert`), with `parent` = the drawing widget's parent's slot.
`Painter` threads a `move_slot` field down exactly as it already threads
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
correct from its first frame — nothing is ever retrofitted onto an
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
- `mov(id, delta)` becomes: look up `id`'s slot, write
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
recursion, since descendants already reference this slot transitively.
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
pixels (after `:106`, before the clip-space divide at `:113`), walks
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
small constant, e.g. 16, with a CPU-side debug assertion that no chain
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
paid every frame regardless of whether anything moved — negligible next
to the per-fragment texture sampling TEXTURES.md already measures this
GPU as not bound by.
**Why the chain, not the flatter thing first proposed.** Iris's own
phrasing — "every instance carries an index into a small per-widget offset
buffer" — describes a flat table: one slot per subtree *declared* movable,
no parent link. It breaks the moment two such subtrees nest — a row inside
a scrolling list, itself later given its own animated offset (a
swipe-to-delete mid-scroll) — because the row's primitives would have to
pick one slot and lose the other's contribution. The chain costs one extra
field and a bounded shader loop in exchange for no such gap, and since
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
no more at the common depth of one than the flat version would.
**Against `region_mut` as the steady-state mechanism**: rejected for being
O(primitives in the subtree) — the cost this section removes — but kept
for a resize that changes a region's `rel` component (a genuine reflow,
§3) and for a size-independent widget's resize (§3), where the content's
shape doesn't change and one field write already suffices.
### 2b. Two more readers of "where is this widget," and masks
Moving the offset into the vertex shader means `ActiveData.region` is no
longer the on-screen truth once a widget has been moved — it is where the
widget was *drawn*, before any `move_offsets` delta. Two things read it as
if it still were, and both must move to a resolved query or they silently
answer with the pre-move position: a click landing on a scrolled row would
be routed to whatever used to be there, with nothing on screen to say so —
exactly the "wrong answer that looks like a right one" case the code rules
single out.
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
does the actual pointer routing, and line 170 is the read in question:
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
immediately turned into pixels and tested against the cursor at `:171-172`.
Under this design that region must be resolved through the same chain the
GPU walks before it means anything. Add to `UiRenderState`:
```rust
/// `active[id].region`, corrected by every `move_offsets` delta between
/// `id` and the root — the CPU-side twin of the vertex shader's chain
/// walk, over the same arena, so the two cannot disagree about where a
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
/// `move_offsets`, bounded by the same constant the shader loop uses
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
/// const across the language boundary).
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
```
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
coordinate query already used outside hit-testing
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
another's on-screen box), is reimplemented to call `resolved_region(id)`
before `.to_px(...)` instead of reading `.region` directly — one change
covers both call sites listed there. `sense.rs:170` changes to
`let shape = self.resolved_region(*id);`. Both are required the moment §2
lands, not an optional follow-up: an unmoved widget's chain is empty and
`resolved_region` costs one arena read to find that out, so there is no
version of this design where skipping the fix is a legitimate
optimization — it is a correctness gap, not a performance one.
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
painter's *current* region into a `Mask` pushed onto
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
widget that called `set_mask` — `Masked::draw`,
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
is itself later moved, its clip rectangle stays where it was drawn while
its content moves out from under it: a visibly wrong clip, immediately on
screen, not a latency question.
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
written from `Painter::set_mask` as `self.move_slot` — the identical slot
the mask-owning widget's own primitives already get (§2), not a second
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
currently computes the mask's `top_left`/`bot_right` inline from
`masks[in.mask_idx]`; that computation is extended to walk the same
move-offset chain §2 added, via one shared function —
```wgsl
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
```
— called from `vs_main` for a primitive's own corners and from `fs_main`
for its mask's corners, so the walk is written once and the two stages
cannot drift apart (the sibling-rule from the code rules: one loop, not a
hand-copied second one in the other shader stage).
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
primitive's mask is frequently owned by a *different* widget than the
primitive itself — often several levels up a subtree, with its own,
independent move slot — so a primitive's resolved offset and its mask's
resolved offset are two different chain sums, both needed, and only the
fragment shader has both `in.move_idx` (this fragment's own chain) and
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
in hand per-fragment. Resolving mask regions on the CPU at move time would
mean, for every `mov()` call, walking forward to every mask instance the
moved widget's slot could affect and rewriting its raw region — exactly
the O(subtree) cost §2 exists to remove, just moved from primitives to
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
frame (`:148`); one more arena read to resolve its chain costs nothing
extra in kind.
**The scroll-container case, checked rather than assumed.** A masked,
scrollable region is built as a `Masked` wrapping a `Scroll`
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
viewport border is drawn (and `set_mask` called) by `Masked`, which is
never itself the target of `mov()`; only `Scroll`'s inner content is,
every frame the user drags. Because each widget's move slot is its own
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
its own, stationary slot, while the scrolled content underneath references
a separate, deeper slot whose `parent` chain passes through — but does not
write to — the viewport's slot. Moving the content therefore never touches
the mask's resolved position, and the mask staying still while its content
slides past it is what this design already produces with no special case,
not an extra rule that had to be added for it.
### 3. Resize scope
A resize is "the region a widget's parent offers it changes such that the
widget's draw might produce different output" — as opposed to a move, which
by construction cannot (§2 is scoped to pure translation). Two independent
narrowings apply, and both are real, measured properties of the code as it
stands rather than new machinery:
**(a) A window resize does not, by itself, require touching most widgets.**
`shader.wgsl:105-106` recomputes every primitive's pixel position from
`window.dim` and the primitive's stored `rel`/`abs` pair *every frame,
already, on the GPU*. A widget laid out purely in `rel`/`abs` terms (no
call to `px_size()`, `output_size()`, or anything else that reads a
concrete pixel count) is therefore already correct after a resize with zero
CPU work — the shader did it. `UiRenderState::needs_redraw_all`
(`render_state.rs:229-231`) currently ignores this and redraws the entire
tree on every `resized`, which was the safe default while sizing and
drawing were two passes; it should be narrowed to only the widgets that
*do* read a concrete pixel value. Track this the same way `needs_redraw`
already tracks per-widget dirtiness (`Widgets::needs_redraw`,
`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself
pixel-dependent by calling through `Painter` methods that read
`output_size`/`px_size` (both already funnel through `Painter`, so the
marking is one line at each), and `resize()` (`render_state.rs:32-35`)
walks only that set instead of unconditionally setting `resized = true`
for a full `redraw_all`. This turns "every resize redraws everything" into
"every resize redraws what depends on pixels" — a real behavior change
beyond what was asked, so verify it against the I0b `pre_present_notify`
resize regression (that fix depended on `redraw_all`'s completeness)
before narrowing this.
**(b) A widget's `available` (its parent's offered region) can change
without the widget's *content* changing — this is what
`is_size_independent` (§1) answers.** When a container's own layout shifts
(a sibling grew or shrank, changing this widget's offered box), a widget
that returns `true` from `is_size_independent` is not redrawn: its
primitives are unaffected by size, only by placement, so the parent
either (i) issues a move (§2) if only position changed, or (ii) rewrites
the primitive's `region` fields directly via `region_mut` if the box
changed shape too (still O(primitives owned directly by this widget, not
its subtree, since a size-independent widget by definition has no
size-dependent descendants worth distinguishing — in practice this is
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
`false` (the default) is redrawn in full whenever `available` changes,
which is correct always, just not free.
**Ancestor propagation** (a resized child changing its own reported size,
requiring its parent to re-lay-out) is unchanged in spirit from today's
`redraw` (`render_state.rs:270-305`), which already walks up exactly the
ancestors whose cached size differs from the new one and stops as soon as
a size is unchanged (`:274-286`). That loop moves from consulting
`Cache.size` to consulting `ActiveData.size` (§5) but keeps its shape.
### 4. Wrapped text, and "needs child height before choosing width"
**Wrapped text is not a special case any more; it already reads as one
draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already
does exactly what single-draw asks for: it reads `ctx.px_size().x` as the
wrap width, shapes once, and memoizes the shaped layout keyed on that width
plus a changed-flag on the buffer and attrs (`:63-69`) — a second call with
the same width is a hash-map-style cache hit, not a re-shape. Under the new
trait this collapses `Text::draw`/`desired_width`/`desired_height`
(`text/mod.rs:133-147`, three functions) into one `Text::draw` that calls
`self.view.draw(painter)` once, which internally still calls `render`
once, hits its own cache, and returns the size it already computed. No
new caching is needed here; the two now-redundant call sites
(`desired_width`/`desired_height` each separately calling `render`) simply
disappear, which is a second `render` avoided per frame per text widget
that is being measured by a parent.
**"Parent wants the child's height before deciding the width it will
offer"** — the genuinely circular case named in the brief, e.g. a column
that sizes its own width to its widest child, where that child is wrapped
text whose height (which the column's *own* height depends on) depends on
the width the column has not yet decided. This is not solvable in one pass
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
(`span.rs:98-136`) already hits exactly this today and already resolves it
by an explicit second, throwaway pass (its own comment: "this literally
copies draw ... which makes this slow and not cool"). The design keeps that
resolution, made explicit rather than accidental: `Painter` gets
```rust
/// Draw `child` at a provisional region to learn its size under one
/// axis's worth of assumption, discard everything it wrote, then draw it
/// again at the region that assumption produced. For the rare parent that
/// cannot pick an offered size without already knowing the answer.
/// Twice the cost of one `draw`; every other case in this file avoids it.
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
```
implemented as: draw at `first`, record `Size`, remove the widget and its
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
"if not \[same region\], maintain resize and track old children," `:97-100`,
which already frees the old primitives before redrawing) — reusing that
path rather than adding a second one — draw again at `second(size)`, return
the final `Size`. It is opt-in and named for its cost, so a widget only
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
call site converted to it, replacing the hand-rolled duplicate loop.
### 5. Caching and invalidation
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
answer, independent of drawing) no longer exists as a separate query, so
there is nothing left to cache at that layer. What already provides "an
unchanged subtree costs nothing" is the check `draw_inner` performs before
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
its region is unchanged, and it is not marked dirty, `draw_inner` returns
immediately — no `Painter` constructed, no primitive touched, no shader
work beyond what the GPU already redraws from the unchanged instance
buffer. That check is kept exactly as it is; it is the caching mechanism,
and it already operates at (id, region) granularity, which subsumes "(id,
available size)" once size *is* what a region change means.
What is added: `ActiveData` gains `pub size: Size` — the value `draw`
returned, stored the moment it is (`draw_inner`, alongside building the
`ActiveData` struct at `:134-143`). This is what a parent placing this
widget for a second frame without redrawing it (because nothing changed)
reads instead of recomputing — it replaces `Cache.size`'s role of "answer a
size question without a full draw" with "read the size of the last actual
draw," which is always available because `draw_inner`'s skip path is only
reachable once the widget has been drawn at least once. `Cache::remove`/
`Cache::clear` (`cache.rs:9-17`) are deleted with the type; `ActiveData`
already has an equivalent lifecycle (removed in `remove`/`remove_rec`,
`render_state.rs:171-198`, freed with the widget).
### 6. Before / after
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
```rust
// before
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) {
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
thickness: self.thickness, inner_radius: self.inner_radius });
}
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
}
```
```rust
// after
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) -> Size {
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
thickness: self.thickness, inner_radius: self.inner_radius });
Size::REST // fills whatever it was given -- used == available
}
fn is_size_independent(&self) -> bool { true } // content never depends on region size
}
```
**A container that needs the child's size before placing it,
`iris/src/widget/position/align.rs`**:
```rust
// before
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) {
let region = match self.align.tuple() {
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
UiRegion::new(x, UiSpan::FULL) }
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
UiRegion::new(UiSpan::FULL, y) }
(None, None) => UiRegion::FULL,
};
painter.widget_within(&self.inner, region);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
}
```
```rust
// after
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) -> Size {
let full = painter.region();
// Draw once at the full region to learn the child's real size --
// this placement is provisional and corrected below without a
// second draw.
let used = painter.widget_within(&self.inner, full);
let region = match self.align.tuple() {
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
(None, None) => full,
};
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
used
}
}
```
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
return type from `()` to `Size`, carrying the child's `draw` result back —
the only signature change needed to let a parent see what its child used.
`Painter::reposition` is new, computing the delta between where a child
was actually drawn and where it belongs and calling the O(1) `mov` from
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
180-182`) are deleted — nothing calls `desired_len` any more, so there is
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
deletes the `SizeCtx` copies, keeping the `Painter` ones.
### 7. Migration — every file and widget that changes
One change, in dependency order (rename-and-move-together, per the code
rules — no intermediate state with both trait shapes):
- `core/src/widget/mod.rs` — the `Widget` trait (§1), delete
`WidgetAxisFns`, update `impl Widget for ()`.
- `core/src/ui/size.rs` — delete `SizeCtx` (the type and all its methods).
- `core/src/ui/cache.rs` — delete `Cache` (§5).
- `core/src/ui/painter.rs` — `widget`/`widget_within`/`widget_at` return
`Size`; add `reposition`, `draw_twice`; delete `size_ctx`, `size`,
`len_axis`; `primitive_at` writes `move_idx`.
- `core/src/ui/render_state.rs` — `draw_inner` captures and stores
`ActiveData.size`; `mov` becomes the O(1) offset write (§2); resize
narrowing (§3a); `redraw`'s per-axis loop reads `ActiveData.size`
instead of `Cache.size`.
- `core/src/ui/active.rs` — `ActiveData` gains `size: Size`,
`move_slot: MoveIdx`.
- `core/src/ui/mod.rs` — `UiData` gains `move_offsets`.
- `core/src/render/data.rs` — `PrimitiveInstance` gains `move_idx`;
new `MoveOffset` struct.
- `core/src/render/primitive.rs` — thread `move_idx` through `PrimitiveInst`
and `Primitives::write`, matching `mask_idx`.
- `core/src/render/mod.rs` — bind the new `move_offsets` storage buffer
(group 2, beside `masks`) and its update path.
- `core/src/render/shader.wgsl` — `InstanceInput` gains `move_idx`;
`MoveOffset`/`UiScalar`-shaped storage binding; a shared `resolve_move`
function (§2b) called from both `vs_main` (a primitive's own corners)
and `fs_main` (its mask's corners, once `Mask` carries `move_idx`).
- `core/src/ui/render_state.rs` — additionally, `resolved_region` (§2b)
and `window_region` (`:264-267`) reimplemented on top of it.
- `src/default/sense.rs` — `run_sensors`'s hit-test read (`:170`) switches
from `self.active.get(id).unwrap().region` to `self.resolved_region(*id)`
(§2b) — the pointer-routing fix this design requires, not an optional
follow-up.
- `core/src/render/data.rs` — additionally, `Mask` (`:46-49`) gains
`move_idx: u32` (§2b).
- `core/src/ui/painter.rs` — additionally, `set_mask` (`:49-52`) writes
`move_idx: self.move_slot` into the `Mask` it pushes (§2b).
- Every widget with a two-method `impl Widget`, collapsed to one `draw`
(§1, §6), `is_size_independent` added where true: `core/src/widget/mod.rs`
(`impl Widget for ()`), `iris/src/widget/rect.rs` (`Rect`, → true),
`iris/src/widget/image.rs` (`Image`, → true — a decoded image's primitive
never depends on the region it is offered, same as `Rect`),
`iris/src/widget/mask.rs` (`Masked`), `iris/src/widget/ptr.rs`
(`WidgetPtr`), `iris/src/widget/text/mod.rs` (`Text`, §4),
`iris/src/widget/text/edit.rs` (`TextEdit`),
`iris/src/widget/position/scroll.rs` (`Scroll`, keeps its `mov`-shaped
offset, now O(1) automatically via §2), `iris/src/widget/position/align.rs`
(`Aligned`, §6), `iris/src/widget/position/max_size.rs` (`MaxSize`),
`iris/src/widget/position/layer.rs` (`LayerOffset`),
`iris/src/widget/position/pad.rs` (`Pad`),
`iris/src/widget/position/stack.rs` (`Stack`),
`iris/src/widget/position/offset.rs` (`Offset`),
`iris/src/widget/position/span.rs` (`Span`, §4's `draw_twice` for the
cross-axis case, deleting `desired_ortho`'s duplicate loop),
`iris/src/widget/position/sized.rs` (`Sized`).
This list was produced by `grep -rn "impl Widget for\|fn desired_width\|fn desired_height"`
across `core/` and `src/`; re-run it before starting, since it is the
authoritative check that nothing was missed, not this paragraph.
- `iris/examples/{minimal.rs,task.rs,view.rs,tabs/main.rs}` — no direct
`impl Widget` found in any example (verified by the same grep); they use
the builder DSL in `core/src/widget/trait_fns.rs` and should need no
source change, which is itself part of the pass condition below.
### 8. Pass conditions
1. **Every example under `iris/examples` renders identically.** Run
`iris/run-headless.sh EXAMPLE --shot PNG` for each of `minimal`, `task`,
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
"looks right," since a subtle wrap or alignment regression is exactly
what a diff catches and a glance does not.
**Result (2026-09-04): pass, all four, 0 differing bytes.** No PNG
library is installed in this VM (no PIL, no ImageMagick, no pip), so the
diff is a from-scratch PNG decoder (`zlib` + the five filter types) at
`/tmp/layout-shots/pngdiff.py`, comparing decoded pixel bytes rather than
file bytes (`cmp` alone is not conclusive across two separately-encoded
PNGs, though it happened to agree here for `minimal`). Before-shots were
taken with `git stash` at the pre-change commit; `tabs` needed two real
fixes (deviations 1 and 2 below) before it stopped differing — the other
three matched on the first try.
2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside
the existing `debug_layers`/`active_widgets` instrumentation
(`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b)
`Primitives::write`/`region_mut` calls, both per `update()` call. Drive
one example (`tabs`, since it already has multiple widgets and an
interactive element) through one frame with nothing changed and report
both counts — the pass condition is **0 draws and 0 primitive rewrites**
for a frame in which nothing was marked dirty, resized, or moved.
**Result (2026-09-04): pass, 0 and 0.** Implemented as
`UiRenderState::take_counters() -> (u64, u64, u64)` (draws, `region_mut`
rewrites, `move_offsets` writes — a third counter, for condition 3
below), reset on read. Measured in
`iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing`
against a `Scroll` over 500 fixed-height rects (not the `tabs` example —
see the note on condition 3 for why this runs as a plain unit test
instead).
3. **Single-moved-child cost, measured.** Same counters, one frame in
which exactly one widget is moved (not resized) with N primitives in its
subtree — the pass condition is **1 write to `move_offsets`, 0 calls to
`Widget::draw`, 0 calls to `region_mut`**, independent of N. Construct
the case with a `tabs`-style example holding a deliberately large text
block (hundreds of glyphs) inside a `Scroll`, so N is large enough that
an O(N) regression would show up as a non-trivial write count rather
than being lost in noise.
**Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets
write, N = 500.** Built with rects rather than glyphs
(`iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw`):
`iris-core`/`iris` touch no GPU or window to lay out and move a tree, so
this runs as a plain `cargo test`, not through `run-headless.sh` — a
`Widgets`/`UiData` pair and a bare `UiRsc` impl are enough, and it is
faster and more precise than reading counters out of a real example's
stderr. Getting a clean single move took two follow-up fixes beyond the
design as written (deviation 3, the `parent_move_slot` threading; and
the `Scroll` design decision below about offering last frame's content
length) — without either, the count was in the thousands (every rect in
the subtree redrawing) rather than 1.
4. **Hit-testing follows the move, not just the render.** In the same
scrolled-`tabs` construction as condition 3, scroll the content, then
send a synthetic cursor position over a widget that moved and assert
`run_sensors` (`src/default/sense.rs:154-200`) routes to that widget's
id, not to whatever is now at its pre-scroll coordinates or to nothing.
This is a correctness check, not a timing one — §2b's fix is required
before §2 can ship at all, and this is what would fail silently
(nothing on screen indicates a missed or misrouted hit) if it were
skipped.
**Result (2026-09-04): pass**, but checked one level below
`run_sensors`: `iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget`
scrolls a widget and asserts `UiRenderState::resolved_region` (the
query `run_sensors`'s hit-test and `window_region` both now go through,
per §2b) reports the moved, not the pre-scroll, position — within
0.01px of the exact expected delta. `run_sensors` itself needs a
`HasEvents`/window/cursor-state harness this pass did not build; the
coverage that matters (does the position query the router uses reflect
the move) is exercised directly instead.
5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll`
both before and after scrolling it (`iris/run-headless.sh` against a
small purpose-built example, or an addition to `tabs`), and diff the
two frames: the clipped edge of the content must have moved with the
scroll while the viewport's own border (drawn by `Masked`, not moved)
stays put — the specific case worked through in §2b. A mask rectangle
that stayed at its pre-scroll position while its content slid past it
is the regression this checks for, and it is visible in a single
screenshot, not just in a counter.
**Result (2026-09-04): pass, checked numerically rather than by
screenshot.** No example in this repository builds a `Masked`-wrapped
`Scroll` (`tabs`'s "text edit scroll" tab uses `TextEdit`'s own internal
scrolling, not this widget), so there was nothing to screenshot without
first authoring a new example. Checked instead in
`iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves`,
on the exact data the fragment shader's `resolve_move` reads: the
masked widget's own `move_offsets` slot delta is `[0, 0]` both before
and after scrolling its content, because `Masked` is never itself the
target of a move — only its child is, on a separate, deeper slot in the
chain (§2b's "scroll-container case, checked rather than assumed"). A
pixel-level screenshot check of this remains open; see RUST.md's next
step.
6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`**
stay clean at the defaults (iris has no tests today per I0b, so this is
presently only clippy/fmt; add the first real widget-layer tests here if
the move-offset chain or `draw_twice` are non-trivial enough to want
one, per "match the codebase's testing posture" — judge that once the
code exists rather than pre-committing to a number of tests here).
**Result (2026-09-04): pass.** `cargo fmt --all -- --check`,
`cargo build --workspace --all-targets`, and `cargo clippy --all-targets`
are all clean (one pre-existing, unrelated warning about `naga`/`wgpu`/
`winit` future-incompatibility, from dependencies, not this change).
`cargo test --workspace`: the 14 pre-existing `TextEdit` tests plus 4 new
ones in `iris/src/layout_tests.rs` (conditions 25 above), 18 passed, 0
failed — the move-offset chain turned out non-trivial enough (three real
bugs found only by writing it) to clearly clear the "match the testing
posture" bar this section left open.
### 9. Rejected, and why
- **A flat (non-chained) per-subtree offset table**, Iris's literal
phrasing — rejected in §2 for breaking under nested independent moves
(a swiped row inside a scrolling list). Costs nothing extra to avoid: the
chain is the same mechanism with one more field.
- **Keeping `region_mut` recursion as the only move mechanism** — rejected
as the steady-state path (O(primitives in subtree), exactly what a
transcript scroll must not pay every frame) but kept for resize-shaped
changes (§3) where the content's own region field, not an ancestor
chain, is what has to change.
- **A second, size-only trait method kept alongside `draw`** (e.g.
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
implement to skip a draw when a cheap answer exists) — considered and
rejected: it reintroduces exactly the "two names for one concept" split
this change removes, for a saving `is_size_independent` (§1, §3b)
already covers for the cases where it would actually help (fixed-size
leaves). A widget whose size is cheap to compute but whose *drawing* is
not (unlikely in this codebase's widget set, but conceivable) is better
served by that widget caching its own draw output internally — exactly
the pattern `TextView::render` already uses (§4) — than by a second
trait method every implementor has to reason about.
- **Passing `available` as an explicit parameter to `draw`** (mirroring
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
which already carries the same information into every widget that needs
it; adding a parameter would just be a second route to a value already
reachable, and would invite the two drifting apart.
- **Eagerly propagating a moved widget's delta into every descendant's own
offset value** (rather than chaining and resolving in the shader) —
rejected as O(descendant widgets), which is smaller than O(primitives)
but still not O(1), and the shader-side chain costs nothing extra to get
the better bound.
## Deviations found during implementation (2026-09-04)
Five corrections this file's first draft did not anticipate, each found by
`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical
pre-change screenshot (pass condition 1) and traced with `eprintln!` in
`draw_inner`/`reposition` — not by reasoning about the design in the
abstract. Recorded here rather than silently fixed in place, per the code
rules' escape-hatch requirement.
1. **`Aligned`'s provisional draw must call `painter.widget`, not
`widget_within(&self.inner, painter.region())`.** §6's original text drew
the sample as the latter. `widget_within` composes its `region` argument
as *local*, `UiRegion::FULL`-relative coordinates against
`painter.region()` (exactly what `UiRegion::FULL.within(&self.region) ==
self.region` relies on); handing it `painter.region()` itself —
already-resolved, window-relative coordinates — composes that frame a
second time. For the root widget this is silently the identity (its
region already is `[0,1]`), which is why it can look correct in a
trivial case and only breaks once something is nested — i.e. always, in
practice. Symptom: a centered child rendered at a wildly wrong offset
nested more than one level deep. Fixed by using `painter.widget`, which
hands the child `self.region` unmodified, with no second composition.
2. **A widget that reports a size smaller than its offered region must
actually paint at that size, anchored top-left of what it was given —
not fill the full offered region while merely *reporting* a smaller
number.** `Sized` and `MaxSize` both had exactly this bug: their
`desired_width`/`desired_height` predecessors capped the *reported*
value but their `draw` bodies called `painter.widget(&self.inner)`
unconstrained, which was harmless under the old two-pass model (a parent
always queried the size *before* drawing, so by the time `draw` ran the
offered region already matched) but wrong under `Aligned`'s new
provisional-draw-then-reposition pattern, which offers the *whole*
region on the first, learning pass. Symptom: a `.sized((100, 100))` rect
rendered stretched to fill its whole row instead of a 100×100 square.
Fixed by having both widgets carve the declared sub-region (`UiSpan`
sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever
they were offered before drawing the child in it. `Image` needed the
same treatment from the start (`texture_within` at its own natural size,
not `texture()` at the full offered region) and was written that way in
the first pass, once this was understood; `Rect`'s "fill whatever I'm
given" is the one case where painting the *whole* offered region really
is the declared behavior, so it needed no change.
3. **The move-offset chain's `parent` link cannot be found by looking up
the parent's `ActiveData` in `draw_inner`, because the parent's
`ActiveData` does not exist yet while its own `Widget::draw` is still
running.** `ActiveData` is inserted only after `draw` returns
(`render_state.rs`, end of `draw_inner`), so a child drawn partway
through its parent's `draw` body — the ordinary case, since every
composite widget draws its children from inside its own `draw` — would
always read "no parent" from `self.active`, silently orphaning it at the
root of the chain. Fixed by threading the parent's `move_slot` down
through `Painter` (it already carries `mask`/`layer` the same way) and
passing it explicitly into `draw_inner` as `parent_move_slot`, rather
than deriving it from `self.active.get(parent_id)`. `move_parent_of`
(the `self.active`-based lookup) is kept, but only for `redraw()`, whose
target's parent genuinely is already active at that call site — the
doc comment on it says which is which. Symptom: `reposition` computed
the right delta and wrote it to the right slot, but the shader never
saw it, because the primitive doing the actual painting chained to
`u32::MAX` one level too early.
4. **`Painter::reposition` cannot reuse `active.region` as "where the
widget currently is," because for a widget offered more room than it
used, `active.region` is the *offered* box, not the *painted* one.**
This only matters for `reposition` (used by `Aligned`); `mov` (used by
`draw_inner`'s own same-size-different-position dispatch, for `Scroll`
and `Offset`) has no such gap, because there the offered region *is*
the visual footprint — content is sized to fill exactly what it is
given. `reposition` instead reconstructs "from" as `active.size`
(already tracked, per §5) anchored at `AxisAlign::Neg` within
`active.region` — i.e. it assumes the child painted itself top-left of
whatever it was offered, per point 2's convention — and **overwrites**
the slot's delta rather than accumulating it the way `mov` does, since
"from" is recomputed fresh from stable inputs every call and repeating
the same `reposition` (an unrelated redraw elsewhere re-running this
widget's parent) must not drift further each time. The one shape this
does not cover: `Aligned` wrapping `Aligned`, where the inner one's own
`reposition` may have moved its content away from top-left already. No
widget or example in this codebase builds that today; if one needs to,
`reposition` would need the child to report *where* it painted, not
just how big, which is a larger change than this pass's scope.
5. **A widget's `move_offsets` slot is allocated once, on its first-ever
draw, and reused in place — never reallocated — for every later redraw
of the same id, with its delta reset to `[0, 0]` on each reuse.** Not
spelled out in §2's original text, which only said slots are assigned
"when the widget is first drawn." Reallocating a fresh slot on every
redraw would leave any *retained* (not-redrawn) descendant's `parent`
link pointing at a now-orphaned old slot — a permanent leak, and worse,
a descendant that silently stops tracking its ancestor's future moves.
Resetting the delta on reuse (rather than carrying it forward) is
required because a full redraw bakes the widget's correct absolute
position into the fresh `region` argument directly; a stale delta left
over from before the redraw would double-offset it.
Two further points worth recording because they were *design decisions*
made while implementing, not bugs — `LAYOUT.md`'s own text left them
unspecified rather than getting them wrong:
- **`Scroll` offers its content a region sized by the *previous* frame's
measured content length, not a fresh one.** A fresh measurement would
require drawing the content once to learn its size and — since that
provisional size essentially never matches the previously active one —
redrawing it a second time at the real size, on every single scroll
tick, which is exactly the cost §2 exists to remove. Using the stale
length means an ordinary scroll (position changes, content does not)
offers the same *size* as last frame, only shifted, which is what makes
`draw_inner` dispatch it as the O(1) move. The cost: a real content-size
change lags one frame before the container's scroll range reflects it,
self-correcting the frame after (the content length itself, read from
what was actually drawn, is never stale — only the offered *region* used
for placement is). No example in this repository builds a `Scroll` yet,
so this could not be checked against a pixel diff; it is covered instead
by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which
build a tree and drive `UiRenderState` directly with no GPU or window
needed.
- **`redraw()`'s parent-relayout check draws the widget first, then
compares the fresh `ActiveData.size` the draw produced against the size
from before removal** — the mirror image of the old code's "query size,
compare, decide whether to draw," which no longer has a size query to
do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx`
along with `desired_width`/`desired_height`). This can occasionally draw
a widget once more than the old code would have (if the parent it
bubbles up to ends up redrawing the same widget again as part of its own
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
redundancy for free, and this path is not one of §8's measured
conditions, so the remaining slack was accepted rather than chased
further.
## For IRIS.md
When this lands, copy this entry into `IRIS.md` (newest first):
> **2026-09-04 — `Widget::draw` reports the size it used; `desired_width`/
> `desired_height` are gone.** A widget used to implement three methods
> (`draw`, `desired_width`, `desired_height`); it now implements one,
> `fn draw(&mut self, painter: &mut Painter) -> Size`, which draws into
> `painter.region()` and returns how much of it was used. Why: the two
> extra methods routinely re-simulated what `draw` was about to do anyway
> (`Span::desired_ortho` copied its own draw loop to get cross-axis sizing
> right) — one visit per widget per frame instead of up to three. A
> container that needs a child's size before placing it (alignment,
> centering) draws the child once at a provisional region, reads the
> returned `Size`, and calls the new `Painter::reposition` to move it into
> its final spot — an O(1) offset write, not a second draw. A widget whose
> drawn output never depends on the size it's given (a fixed-size `Rect`,
> a decoded `Image`) overrides the new `fn is_size_independent(&self) ->
> bool { false }` to `true`, which skips redrawing it when only its
> offered region changes shape.
>
> ```rust
> // before
> fn draw(&mut self, painter: &mut Painter) { /* ... */ }
> fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
> fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
>
> // after
> fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
> ```
>
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
> design, the move-offset mechanism this shipped alongside, and the file
> list.
+691 -1041
View File
File diff suppressed because it is too large. Load diff
+2632
View File
File diff suppressed because it is too large. Load diff
+496
View File
@@ -0,0 +1,496 @@
# How iris should render an unbounded number of images
## Status (2026-09-04)
**Implemented**, on the `rustify` branch of `ai-app-2`, in `iris/core` and
`iris/src/default/render.rs`. See "Implemented, 2026-09-04" at the bottom for
what landed, what differs from the proposal below and why, and what was
verified versus merely reasoned about. The short version: the binding array
is gone, `request_device` asks for no features and no binding-array limits,
and that is now proven on the emulator's software Vulkan
(`rigs/gpu-probe`), not just read from the code. `RUST.md`'s blocking item
is resolved.
Iris (the person) asked whether iris's (the library's) approach to
"draw however many images happen to be on screen" — relevant here because a
transcript can hold an unbounded number of attached screenshots — actually
works on mobile, her recollection being that it does not. Checked rather
than assumed, on 2026-09-04, on the `rustify` branch of `ai-app-2`. This
file is that investigation and the resulting recommendation, written for a
second agent to review before anything in iris's render core changes — no
code has been written against this yet.
## The problem
Every texture iris ever creates — every `Image` widget
(`iris/src/widget/image.rs`) and every glyph atlas page — gets a permanent
slot in one array via `Textures::add` (`iris/core/src/primitive/texture.rs:65`).
Both of iris's texture-sampling primitives (`TEXTURE` and `GLYPH`) read that
array by index: `core/src/render/shader.wgsl:56` declares
`var views: binding_array<texture_2d<f32>>`, sized by
`UiLimits::default()` (`core/src/render/mod.rs:347`) at **100,000 textures,
1,000 samplers**. Getting a device to accept that layout needs three wgpu
features — `TEXTURE_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
`PARTIALLY_BOUND_BINDING_ARRAY` — which correspond to Vulkan's
`VK_EXT_descriptor_indexing` ("bindless"), promoted to Vulkan core at 1.2.
A transcript with an unbounded number of image attachments is exactly the
case that grows this array without bound: each attachment becomes its own
`Image` widget, which takes its own permanent array slot until dropped.
## What was measured
**A new rig, `rigs/gpu-probe`**, asks a device for exactly iris's features
and limits with no window and no APK — a plain executable pushed with
`adb push` and run from `/data/local/tmp`. It has two parts:
`wgpu::Adapter::request_device` with iris's exact `Features`/`Limits`
(`src/main.rs`), and a raw Vulkan query bypassing wgpu entirely via `ash`
(`src/vk.rs`), to tell "the driver doesn't have it" apart from "wgpu didn't
detect it."
- **On this VM's own GPU** (Vulkan via Venus onto an RX 7900 XT):
`IRIS DEVICE: ok`. Not the case that matters — nobody's phone is a
discrete desktop GPU — but it is why the design was never checked before
now: it always worked in the one place it was tried.
- **On the Android emulator's guest Vulkan**, both ICDs it ships
(`vk_swiftshader_icd.json` and, cold-booted, `lvp_icd.json`/lavapipe):
`request_device` **fails**
`Unsupported features were requested: TEXTURE_BINDING_ARRAY |
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
PARTIALLY_BOUND_BINDING_ARRAY`. The raw `ash` query on lavapipe shows the
driver itself reporting all seven descriptor-indexing sub-features as
`true` at device API version 1.3 — so wgpu-hal's own feature detection is
being more conservative than the driver here, for a reason not chased
further (a likely instance-version negotiation gap, since the extension
only promoted to core at 1.2). That part is a wgpu-hal/emulator question,
not the finding that matters, and is **not** why this design is rejected.
**The finding that matters is about real phones, sourced rather than
recalled:**
- The **Android Vulkan Profile 2025** — Google and Khronos's current
baseline, covering **80.1% of active Vulkan-capable Android devices** as
of October 2025
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)) —
does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
(indexing by a value uniform across the invocation — Vulkan 1.0 baseline,
unrelated to bindless) and stops there; true of the 2021 and 2022
profiles as well.
- Arm's own developer documentation states **"`VK_EXT_descriptor_indexing`
is supported on all Valhall and 5th Gen GPUs"**
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
Mali generations from roughly 2019 (Mali-G77) onward, with no claim made
for Bifrost, Midgard or Utgard, which are still common in budget and
older Android phones that are still in daily use.
- A search engine's summarized claim of "1% support on Android" for this
extension was checked against its cited source (an Arm blog post from
2021) and **was not actually there** — that number does not appear in
any primary source found and should not be repeated. The baseline-
profile finding above is the one with an attributable source; use it
instead.
So this is not a software-renderer artifact. A real, currently-shipping
share of the Android fleet lacks the feature iris's texture pipeline asks
for unconditionally, and neither the emulator's failure nor the current
official hardware baseline gives any reason to expect that to change soon.
## What growth already costs today, before any redesign
Checked directly in `core/src/render/mod.rs` and `core/src/render/texture.rs`,
because "does this redesign make things worse" needs the current baseline
first:
- The `RenderPipeline` (`UiRenderNode::new`) is created **once** and never
rebuilt for any reason related to texture count — its bind group
*layouts* declare fixed slot counts (`limits.max_textures`,
`limits.max_samplers`) up front and that never changes at runtime. Growth
was never at risk of recreating the pipeline, in the current design or
any redesign discussed below.
- What **does** get rebuilt: `UiRenderNode::update` calls
`self.textures.update(&mut ui.textures)`, and if that reports any change,
rebuilds `self.rsc_group` — one `BindGroup` whose entries are
`BindingResource::TextureViewArray(&tex_manager.views())`, collected
fresh over **every currently-live texture**, plus the sampler array and
the mask buffer. This happens on every texture `Push`, `Set`, or `Free`
— an image added anywhere in the whole app rebuilds one shared structure
referencing every other image too.
- The one path already excluded from this, on purpose, is a `Patch`
writing into an existing texture's pixels without changing which
textures exist. The code says why directly
(`core/src/render/texture.rs`, in `GpuTextures::update`): *"A patch
changes texture contents, not the binding array, so it must not report
`changed` — rebuilding the bind group per glyph is the cost this exists
to avoid."* This is exactly the mechanism I1 built for the glyph atlas:
growing an existing atlas page costs a `write_texture` into a sub-rect,
nothing else.
So today, growth that stays inside an existing texture (glyphs added to an
atlas page) is already free. Growth that adds a *new* texture — a new atlas
page, or any standalone image — already rebuilds the one shared array
regardless of how the array is populated, before any change discussed
below. That existing cost is O(live texture count) in CPU work to collect
the view list and in however expensive the driver finds a
descriptor-set-sized-for-N-descriptors to be.
## Prior art, checked rather than assumed
Two independent projects were checked to see whether "atlas for images"
is actually how this is normally done, rather than a guess:
- **egui_wgpu** (`crates/egui-wgpu/src/renderer.rs` in emilk/egui), the
closest prior art to iris — an immediate-mode wgpu-backed UI library that
ships on Android. It keeps a `HashMap<TextureId, Texture>` and gives
**each texture its own ordinary `BindGroup`** — one texture, one sampler,
no array, no descriptor indexing of any kind. Draw calls are batched by
texture id and the bind group is switched between batches within the
render pass.
- **Vello** — the renderer Masonry (E1/E2's Linebender stack) draws
through — hit the identical problem and wrote down why in their own
roadmap document
([github.com/linebender/vello/blob/main/doc/roadmap_2023.md](https://github.com/linebender/vello/blob/main/doc/roadmap_2023.md)):
*"The number of images that may appear in a scene is not bounded, which
is not a good fit for the basic descriptor binding model... Until then,
we'll do a workaround of having a single atlas image containing all the
images in the scene."* Their reason is broader than Android — WebGPU 1.0
has no descriptor indexing at all — but it reaches the same conclusion
for the same shape of problem: atlas, not a bigger bindless array.
**This is also a live hazard, not a solved one.** Vello's own changelog
(Sparse Strips v0.2.0) lists a fix titled *"WebGL image-atlas allocation
and growth on Mali-G52 GPUs, avoiding application-not-responding errors"*
— an actual ANR, from atlas growth, on an actual mid-range Android GPU,
in the renderer Masonry is built on. The same release added
`AtlasSpaceDiagnostics`/`AtlasLayerDiagnostics` (per-layer free-space,
utilization, fragmentation) because growth needed instrumenting in
production, not because it turned out to be free.
## Recommendation (not yet implemented)
1. **Small, plentiful textures** — glyphs (already done, I1), thumbnails,
downscaled attachment previews, icons — go through a shared atlas, the
same technique as `core/src/render/atlas.rs` generalized beyond glyphs.
Adding one to an existing page is a `Patch`, already free per the
section above.
2. **Large or one-off images** — a photo attachment opened at full
resolution, anything that would fragment a shared page — get their
**own ordinary, non-array bind group**, the egui_wgpu way. Creating one
is O(1): it references only itself, and does not touch any other
texture's binding, unlike today's shared array where every push
rebuilds a structure listing everything.
3. **Opening a new atlas page** is the one case that still resembles
today's rebuild — infrequent (bounded by how many *pages* are needed,
not by how many images have ever been attached) but not free, and
Vello's Mali-G52 fix says this specifically deserves care: it should
never be allowed to block a frame, and it is worth having the
equivalent of Vello's atlas diagnostics before trusting it under load.
4. **Net effect**: dropping `TEXTURE_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`, and
`PARTIALLY_BOUND_BINDING_ARRAY` from iris's device request entirely.
Every path above is plain Vulkan 1.0 / GLES-level texture sampling.
This is also what fixes the emulator failure measured above, regardless
of the unresolved wgpu-hal question: a device that never asks for the
feature cannot be refused for lacking it.
## What this touches, and what is still open
Implementing this reworks iris's rendering core: the shader's binding
group layout (`shader.wgsl`), `Textures` and `GpuTextures`
(`core/src/primitive/texture.rs`, `core/src/render/texture.rs`), both
texture-sampling primitives, and `core/src/ui/painter.rs`'s draw-call
batching (today one draw call can reference any texture by index; the
per-texture-bind-group path needs draws grouped by which bind group they
use). Nothing has been started.
Open questions a reviewer should weigh in on:
- **The size threshold** between "goes in an atlas page" and "gets its own
bind group." Too low and ordinary attachment thumbnails end up as
one-off bind groups, losing the batching benefit the atlas exists for;
too high and a page fragments on a handful of medium images.
- **Eviction policy** for atlas pages once the working set does not fit —
today's `GlyphAtlas` never evicts, because a font's glyph set is small
and bounded; images are not. An LRU at the page level, or at the
individual-image level within a page, has not been designed.
- **Whether iris should keep any binding array at all**, even a small
fixed one (say, capped at a few dozen slots) for atlas pages themselves,
or whether every atlas page should also be its own ordinary bind group
like standalone images — the array's only remaining justification would
be avoiding a bind-group-per-draw-call switch cost that has not been
measured on this project's actual target hardware.
- **How this interacts with I2/E2's virtualised list** (I3): a
bottom-anchored transcript composes only visible rows, so the live
texture set should already be bounded by what is on screen rather than
by the whole conversation — worth confirming that invariant holds before
relying on it to keep atlas/bind-group churn small.
## Review, 2026-09-04
A second pass over the file above against the code, done before anything
is implemented. Iris's worry going in: a bind group per texture means a
draw call per image, and she wants this as efficient as it can be.
### What checked out
Every code reference above is accurate as of this commit: the 100,000 /
1,000 limits, the one-time pipeline, the `rsc_group` rebuild on every
`Push`/`Set`/`Free`, and the `Patch` exclusion. The device request that
asks for the three features is `iris/src/default/render.rs:96`, which the
text above does not name. egui-wgpu and Vello are described correctly.
### The emulator refusal is a wgpu-hal gap, now located
The file guessed "a likely instance-version negotiation gap." It is
narrower than that and it is in wgpu-hal, not the emulator. wgpu-hal
28.0.0 (`src/vulkan/adapter.rs:1618`) only queries
`PhysicalDeviceDescriptorIndexingFeaturesEXT` **when the device advertises
the `VK_EXT_descriptor_indexing` extension string**. A Vulkan 1.2+ driver
that has descriptor indexing as core need not list the extension, and
lavapipe at 1.3 evidently does not, so wgpu never asks and reports the
features absent, which is why `ash` sees seven `true`s and wgpu sees none.
The properties query beside it (line 1486) correctly accepts
`device_api_version >= 1.2 || extension`; the features query does not.
wgpu-hal 30.0.1 in the local registry has the same asymmetry (lines
1872 and 2036). Worth an upstream issue, but not a reason to keep the
design: on real phones the gate that matters is stricter still.
**wgpu's `TEXTURE_BINDING_ARRAY` needs six sub-features, not one**
(`adapter.rs:160-177`): non-uniform indexing *and* update-after-bind for
sampled images, storage images and storage buffers, all together, because
wgpu marks every array-bearing descriptor set update-after-bind. So Arm's
"the extension is supported on Valhall" is necessary but not sufficient;
a driver with sampled-image indexing and without storage-buffer
update-after-bind is refused too. That widens the excluded set beyond
what the Arm quote suggests and strengthens the conclusion.
### A live bug in the current code, found on the way
`GpuTextures::update` (`core/src/render/texture.rs:33`) implements
"a patch must not report changed" as `changed = false`, unconditionally,
which also **cancels a `Push` earlier in the same batch**. That ordering is
exactly what opening a new atlas page produces: `GlyphAtlas::allocate`
pushes the page and `insert` patches it in the same frame, so the bind
group is not rebuilt and the new page's view is not bound until some
unrelated texture change happens to rebuild it. It is hidden today only
because the masks path also sets `changed`. The fix is one line
(`changed |= !matches!(update, Patch)` in spirit); it should go in with
the redesign since that code is being replaced, and it is recorded here
so it is not rediscovered.
### In-layer draw order is already undefined
Relevant to any batching redesign: `Primitives::apply_free`
(`core/src/render/primitive.rs:147`) uses `swap_remove`, so the instance
order within a layer is permuted whenever anything is freed. Overlap order
inside one layer is therefore not something the renderer promises today;
ordering is done with layers. That means grouping a layer's draws by
texture, or drawing a layer's images after its rects and glyphs, loses
nothing that currently exists. It should be written down as an invariant
when the redesign lands, because the new code will depend on it.
### On "a draw call per image"
Two corrections to the worry. First, it is a draw per *distinct texture per
layer*, not per image primitive: every glyph quad in a layer shares the
atlas and stays one instanced draw, and a thumbnail atlas would do the same
for previews. Second, the count is bounded by what is on screen, which I3's
virtualised transcript already bounds, and a mobile GPU is not draw-call
bound at tens of draws per frame; egui ships exactly this on Android. What
does cost is per-frame *bind group creation* and per-frame *sorting*, and
the current code already creates a `primitive_group` bind group every time
a layer updates (`render/mod.rs:103`), so one more per new image is not a
regression in kind.
### Recommended shape (proposal, for Iris to accept or change)
Aimed at the fewest moving parts that need no feature beyond Vulkan 1.0:
1. **Atlas pages become layers of one `texture_2d_array`**, not separate
textures. Every page is already `PAGE`x`PAGE` RGBA8, which is the one
constraint an array texture imposes. A layer index is an ordinary
sampling operand in WGSL and needs no indexing feature, so `GLYPH`
(and any future atlased-image primitive) carries a layer instead of a
`view_idx` and all of a layer's text stays **one draw**. This answers
the open question above about keeping a small binding array: no. Cost
of opening a page: recreate the array with one more layer and
`copy_texture_to_texture` the old ones, GPU-side, no readback; grow
with headroom (double) so it is rare. wgpu's default
`max_texture_array_layers` is 256, at 4 MB each, so the cap is memory
rather than the API.
2. **Every standalone image is its own texture with its own bind group**,
and its instances live in a **separate per-layer instance list**, not
the main one. Then the main instance buffer never contains an image,
there is nothing to sort, no handle remapping beyond what
`apply_free` already does, and each image is `draw(0..4, k..k+1)` with
its bind group set first. Group 2's layout becomes `{atlas array,
one image texture, sampler, masks}`; the main draw binds a 1x1 null
image in the image slot, each image draw binds its own. One pipeline,
one shader, one layout.
3. **No thumbnail atlas in the first version.** With images on their own
textures, the threshold and eviction questions above disappear: an
image is freed when the row that owns its `TextureHandle` scrolls out.
Add an image atlas only if a measured screen shows enough small images
to matter, which a transcript rarely does.
4. **Drop the three features and the two `max_binding_array_*` limits from
`src/default/render.rs`**, and the `UiLimits` counts with them.
5. **Sampling is `NonFiltering` today** (`render/mod.rs:290,299`), so a
downscaled attachment will alias. Either request a filtering sampler
for the image slot or downscale on the CPU before upload; decide when
the image widget is touched, not as part of this.
What this costs against the file's original recommendation: `Textures`
needs to know an image from a page (two kinds of handle, or a kind on
`TextureHandle`), and `Primitives` gets a second instance list per layer.
What it saves: the sort, the size threshold, the eviction policy, and any
per-page bind group switch.
## Implemented, 2026-09-04
The shape above, built as proposed with one structural addition the proposal
didn't need to spell out and one bug it predicted made moot rather than
literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`,
`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`),
`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`),
`core/src/render/atlas.rs`, `core/src/ui/painter.rs`,
`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed),
`core/src/render/shader.wgsl`, `src/default/render.rs`, and
`rigs/gpu-probe/src/main.rs`.
**1. Atlas pages as array layers.** `GpuTextures` owns one
`texture_2d_array` (`array_texture`/`array_view`), grown by doubling
(`grow_array`): a new texture is created at twice the layer capacity, the
old layers are copied across with `copy_texture_to_texture` (GPU-side, no
readback), and every bind group that referenced the old view — the main
one and every live standalone image's — is rebuilt, since the view's
identity changed. `GlyphPrimitive` carries `layer: u32` instead of
`view_idx`/`sampler_idx`; the layer number is assigned synchronously in
`Textures::add_page` (a plain counter, `next_page_layer`), not by the
renderer, because `GlyphAtlas::insert` needs it in the same call, before
any GPU sync happens — the renderer only finds out later, when it
processes the queued `Push`.
**2. Standalone images, one bind group each.** `TextureKind` on
`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index,
`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list
`images: Vec<PrimitiveInstance>`, tagged `IMAGE_BINDING` — separate from
`instances` (rects and glyphs), written by `Painter::write_image` rather
than through the generic `Primitive` trait, since an image has nowhere in
`PrimitiveData` to put a per-instance entry once the bind group already
picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer
once as before, then walks `image_instance` one entry at a time, binding
that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing
`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed
`{atlas array, one image texture, sampler, masks}`; the main draw binds a
1x1 null view in the image slot.
**The one addition beyond the proposal**: the masks storage buffer lives
in every per-image bind group (group 2, binding 3), and `ArrBuf<Mask>`
recreates its buffer whenever the mask count changes size
(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized).
A resize invalidates every bind group holding the old buffer, not just the
main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls
`rebuild_image_bind_groups` when it's set, alongside the same rebuild the
array-growth path already needed. This wasn't a design question the
proposal had to answer (it treated bind-group construction as a given),
but it's exactly the shape of trap layer growth already had, so it uses
the same fix.
**3. No thumbnail atlas.** Not built, as proposed.
**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from
`src/default/render.rs`'s `request_device`, and `UiLimits` (the type
itself, not just its binding-array methods — once its two fields were
gone there was nothing left in it, and `UiRenderNode::new` no longer takes
a limits parameter). `binding_array` no longer appears anywhere in
`shader.wgsl`.
**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own
note that this is a separate decision for whenever the image widget itself
is touched.
**The `changed = false` bug is structurally gone, not patched.** The old
`GpuTextures::update` held one `changed: bool` that a `Patch` reset
unconditionally, which could erase an earlier `Push` in the same batch (a
new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s
`Patch`, both queued before the renderer ever runs). The new `update`
computes the rebuild signal by OR-ing each event's own answer
(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never
contributes to it — there is no shared mutable flag left for a `Patch` to
stomp on. Documented at the call site
(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the
`Patch` match arm's comment) rather than fixed as a one-line diff, since
the mechanism that could go wrong no longer exists.
**In-layer draw order is an explicit invariant now, not just a fact about
`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its
rects and glyphs, and `Primitives::apply_free`'s doc comment states
directly that both of a layer's lists (`instances` and `images`) free with
`swap_remove` and that nothing may assume adjacency survives a free —
recorded there because `apply_free` is the one place a change to either
list's ordering would have to be reconciled.
**Verified:**
- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
`cargo clippy --all-targets`, `cargo test --workspace` all clean in
`iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass
(unchanged from I1; nothing here is pure-logic enough to add a unit
test to — it's all GPU resource wiring).
- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and
`iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly
on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every
panel, confirming `GlyphPrimitive.layer` addresses the array correctly.
- The standalone-image path specifically: a throwaway example (not
committed) with an `image(...)` widget as part of the root, run the same
way, rendered the image next to glyph-atlas text in one frame —
confirming a live `BindGroup` built by `GpuTextures::create_image` and
bound per-`draw()` call actually samples the right texture. `tabs`'s own
"image span" tab exercises the same widget but needs a click to reach,
which the headless compositor can't deliver (no seat devices, per I1's
own note on this file) — the throwaway example is what stood in for it.
- **Exercised, 2026-09-04: `grow_array` under real load, on `tabs`.**
Rather than building a purpose-made glyph flood, `PAGE`
(`core/src/render/atlas.rs`) was temporarily dropped from 1024 to 64 —
small enough that `tabs`'s ordinary mix of sizes and families (nothing
exotic: a handful of `Text` widgets at a few sizes, one at
`Family::Monospace`) already exceeds one page's worth of distinct
glyphs. A one-line `eprintln!` in `grow_array` confirmed two real grows
in a single run (`GROW_ARRAY: 1 -> 2` then `GROW_ARRAY: 2 -> 4`, i.e.
glyphs landed on at least a third layer), and
`iris/run-headless.sh tabs --shot` showed every tab's text rendering
correctly with no corruption or missing glyphs — confirming the
`copy_texture_to_texture` grow-and-relocate path and cross-layer
sampling (`GlyphPrimitive.layer` addressing a layer beyond the first)
both work. Command:
`sed -i 's/PAGE: u32 = 1024/PAGE: u32 = 64/' core/src/render/atlas.rs`,
rebuild, `./run-headless.sh tabs --shot /tmp/x.png`, then
`git checkout -- core/src/render/atlas.rs` to revert — this is a
throwaway diagnostic value, never a committed change, since a real
1024px page holding only a handful of glyphs at a time would be mostly
wasted space in normal use. Confirmed the revert left `tabs` and
`minimal` byte-identical to the pre-check screenshots afterward.
- **The decisive check**, `rigs/gpu-probe` rewritten to request iris's new
(empty) feature/limit set and run on this checkout's own emulator
(`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets
a real Vulkan device (SwiftShader) rather than the `-gpu host` default,
which disables Vulkan in this VM entirely (`-feature -Vulkan`, because
gfxstream can't pair Venus with the real GPU here — worth remembering,
since the *default* `emu up` gives a device with **no** Vulkan adapter
at all, which reads exactly like the old bindless failure if you don't
know to ask for `EMU_GPU=software`):
cd rigs/gpu-probe
ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \
cargo ndk -t arm64-v8a -P 26 build --release
EMU_GPU=software emu up # from ~/repos/emulator-tools
adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/
adb shell chmod 755 /data/local/tmp/gpu-probe
adb shell /data/local/tmp/gpu-probe
Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`,
`features iris requires:` (none listed — the set is empty),
`max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix
measured working, on the exact rig that first measured it failing.
Emulator stopped afterward (`emu down`); nothing was left running.
+427
View File
@@ -0,0 +1,427 @@
# The transcript cache
Asked for by Iris on 2026-09-04 and built the same day: keep the transcripts
of recently visited sessions on the phone, so reopening one does not download
it again. It has to save data over the tunnel, must not disturb a reply that
is streaming when the screen is reopened, must never skip an event, and needs
a manual reload for when the file on the machine has changed under it.
Like EXPLORER.md this records each decision with its reason and what was
rejected, so that when one changes it is changed here rather than re-argued.
"What building it changed" at the foot says which of them moved while it was
being built. How to exercise it, and what has bitten, are in AGENTS.md.
## What it is, in one paragraph
A per-session file on the phone holding the exact JSON lines the server has
already sent, in transcript order, with a record of which sequence numbers
each run of lines covers. Everything the session screen fetches — the opening
window, the pages it scrolls back through, the span an anchor restore reaches
for — is asked of the cache first and of the server only for what the cache
does not hold, and everything that arrives from the server is written into
it. The live stream then resumes from the newest cached event, exactly as it
resumes from the newest event on screen, so the server sends only what
happened since. One tiny request checks that the cached tail is still what
the server has before the stream is opened from it, and a button in session
settings throws the cache away and rebuilds the screen as a cold open for the
cases that check cannot see.
## The invariants
When a decision below looks arbitrary, it is one of these forcing it.
1. **What is on screen is what the server's transcript says, in order, with
nothing missing, for every sequence number the screen claims to show.**
The cache is a copy of server output and is never inferred, folded, or
edited on the phone. Where the copy cannot be shown to be current, it is
thrown away, not patched.
2. **A cached line is never ahead of the live cursor, and the live cursor is
never ahead of the cache.** The stream resumes from the newest cached
event, so a reply that was mid-stream when the screen closed picks up at
its next delta and folds into the same row.
3. **The cache is never load-bearing.** A missing, evicted, corrupt or
unwritable cache degrades to a cold open, never to a blank or wrong
screen. Every path that reads it has a network path beside it producing
the same result.
4. **Data crosses the tunnel once.** A line already on the phone is not
fetched again unless the reader asks (the reload button) or the check in
decision 3 says it must be.
## Decisions
### 1. Raw server lines, on the phone, keyed by server and session
The cache stores the server's own JSON, one event per line, byte-for-byte as
it arrived: the elements of the `/transcript` array and the `data:` payload
of each SSE frame. Reading the cache runs the same `parseSeqEvent` the
network path runs, so a cached transcript and a fetched one cannot draw
differently, and an event type this build does not know
(`SessionEvent.Unknown`) survives on disk for the build that will.
It lives under `context.cacheDir`, which is exactly what that directory is
for: bytes the phone can regenerate from the server, which Android may delete
under storage pressure without asking. Keyed by the server's host and port,
because two servers can hold a session with the same id (the sandbox and the
real server, or a re-enrolment) and a line from one shown against the other
is invariant 1 broken. The `v1` segment is the format version: any change to
the layout below bumps it, and a directory of another version is deleted on
first use.
Rejected: a database (Room, SQLite). The access pattern is "the newest N
lines" and "the lines before seq X", on files of tens of megabytes at most,
and a JSONL file per contiguous run answers both by reading from its end. A
database would be a new dependency for an index the file layout provides.
Rejected: caching folded `TranscriptItem` rows instead of events. Rows are a
*rendering* of events, and their shape changes when the fold changes; the
cache would need invalidating on every app update that touched `foldEvent`,
and would still have to keep raw seqs for the stream cursor. Events are the
server's contract and the only thing that is stable.
### 2. Chunks with explicit coverage; one contiguous run behind the cursor
A page from the server is a set of lines *and a claim about what they cover*,
and the two are not the same thing. A coalesced page joins each run of
`assistantText` deltas into one event carrying the seq of its *oldest* delta,
so a page whose newest event has seq 1,200 may in fact cover every line up to
the `before` it was asked with, say 1,650. Nothing in the lines themselves
says so. So each stored chunk records its coverage as a half-open range
`[first, end)`, where `end` is the `before` the request was made with — or,
for a raw chunk, its newest seq plus one.
Chunks are files named by their coverage:
<first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
<first>-<end>.raw.jsonl an uncoalesced page or a closed live run
<first>-open.raw.jsonl the live run: appended to by the stream
Two chunks are **adjacent** when one's `end` equals the other's `first`. The
cache serves only the contiguous run of adjacent chunks that ends at the
newest raw chunk (the **suffix**); chunks behind a gap are kept on disk,
because the gap is usually filled (decision 4), but are never served across
it.
**The newest chunk is always raw.** That is what makes the stream cursor and
the probe well defined: a raw chunk's last line is a real event at a real
seq, and the server never coalesces the newest window. It holds by
construction — the opening window is fetched with no `before`, stream frames
are raw, and a `reset` window is raw — and is *checked* on read: a `.rows`
chunk found newest (which can only happen if the app died between closing one
live run and appending to the next) purges the session's cache.
There is at most one open chunk. A stream event whose seq is not the open
chunk's `end` — which is what a `reset` looks like from here — closes it by
renaming it with its real end and starts a new one. An event whose seq is
below the open chunk's `end` is already covered and is not written; the SSE
contract is `seq > after`, so that is a guard rather than a path.
Rejected: one file per session, rewritten to prepend older pages. A 20 MB
transcript would be rewritten on every page scrolled back to. The chunk
directory costs a directory listing per open instead.
Rejected: trimming chunks to resolve overlaps. A coalesced event cannot be
split at a seq inside its run, so an overlap between a coalesced page and an
existing chunk has no clean cut. The cache therefore **never stores a page
that overlaps an existing chunk**; decision 4 makes sure such a page is never
fetched, and one that arrives anyway is used for display and not stored.
### 3. The cached tail is checked against the server before the stream opens from it
The transcript file is append-only in ordinary use, but it can be replaced or
truncated — a sandbox re-seeded with the same ids, a backup restored, a
session deleted and re-imported — and `catch_up` on such a file would hand
the phone a continuation of a *different* conversation, spliced onto the
cached one with no seam. That is the worst thing this feature can do, and it
is caught with one request.
**The probe** is `GET /sessions/{id}/transcript?before=<cursor+1>&limit=1`,
where `cursor` is the seq of the cache's newest line. `read_window` with that
`before` returns the single newest event with seq ≤ cursor, which is the
event *at* the cursor when it exists. It passes when that response, parsed
with `parseSeqEvent`, is `==` to the cached line parsed the same way — over
seq, ts, and the whole event. It fails when the response is empty, is a
different seq, or differs in any field.
That equality rested on an assumption this plan stated and did not check:
that the two ways the server hands out a line agree bit for bit. **They did
not**, and the server was fixed — see AGENTS.md's entry on `float_roundtrip`.
Comparing everything *except* `ts` was the other option and was rejected: a
re-seeded fixture is identical in content and differs only in when it
happened, which is exactly the case the probe exists for.
A failed probe **purges the session's cache and proceeds as a cold open**. A
probe that cannot be made leaves the cached transcript on screen, shows the
error on the stream banner where a connection failure shows today, and is
retried on the stream loop's schedule; the stream is never opened until a
probe has passed once for this screen instance.
What the probe does *not* catch: a line changed in the middle of the file
with the tail intact, or a file rewritten so that the event at the cursor
happens to be identical. Those are what the reload button is for, and the
button's caption says so.
Cost: one request of a few hundred bytes, in the slot where the opening
page's request would be — so the round trips before the stream is live are
unchanged at two, and the bytes fall from a page to a line. The cached rows
are drawn *before* the probe returns, which is the whole point; a failed
probe replaces them, with the same appearance as a `reset`.
Rejected: a server-side check on the stream, answered with a distinct frame
when the event at N is not what the phone thinks. Strictly better coverage —
it would run on every reconnect — and no extra round trip. Not chosen because
it puts a cache's validation into a protocol that otherwise knows nothing
about caching, and because the reset frame already has to keep meaning "you
are behind, your history is fine". Worth revisiting if the probe's round trip
is ever measured as the thing making reopen slow.
Rejected: trusting the cache and relying on the reload button. Invariant 1 is
not something a button restores after the fact.
Rejected: fetching the newest page as before and using it to validate the
overlap. Zero saving on the opening page, which is the request paid on every
open.
### 4. Pages ask the server only for the gap: `after` on `/transcript`
After a reader has been away, the cache holds `[a, b)` and the screen holds
the newest window `[W, …)` with a gap between `b` and `W`. Paging back from
`W` asks for a coalesced page before `W`, and that page may reach back past
`b` — a single reply is hundreds of lines, so forty rows can be thousands of
seqs — producing exactly the overlap decision 2 refuses to store. Left like
that, every cached chunk would be dropped in turn as the reader paged back
through the gap, and the cache would save nothing for the sessions it exists
for.
So the transcript route takes a lower bound, `after`, named to match the SSE
route's (exclusive, `seq > after`). `read_window` starts the walk at
`first_at_or_after(after + 1)` instead of at `end - limit`. A delta run cut
at the start is emitted as the partial it is, exactly as one cut by `limit`
already is, and `healSplitMessage` welds it on the phone — no new mechanism.
The phone passes `after = b - 1` where `b` is the `end` of the nearest chunk
whose `end ≤ before`, and nothing when there is none. A page that comes back
with `first == b` is adjacent, and the suffix now runs through the old
chunks: the gap is closed with exactly the bytes it was wide, and the history
behind it is served locally from then on.
Rejected: fetching the gap raw in one request, which is what the anchor
restore does. Exact, but a gap of ten thousand lines is several megabytes
downloaded to save re-downloading history the reader may never scroll to.
Rejected: dropping the cached run whenever a gap opens. Being more than
`CATCH_UP_LIMIT` (200) events behind is the *ordinary* state of an active
session revisited — 200 raw events is one reply — so this would empty the
cache for exactly the sessions that are opened most.
### 5. A page is served locally in rows, mirroring the server's count
`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when coalescing and for
a number of **events** otherwise (the anchor restore). Served from the cache,
the events branch is the `limit` lines before `before`. The rows branch walks
back counting rows the way `parse_coalesced` does — every event that is not
an `assistantText` is a row, and each maximal run of `assistantText` lines is
one row — stopping only between rows. It does not join the deltas; the fold
does that, and the joined row keeps the seq of its first delta either way, so
anchors and the next `before` land where they do on the network path.
A cached page is allowed to be **short**: a walk that reaches the suffix's
oldest chunk returns what it found. The caller already treats a short page as
a page; only an *empty* page means "start of the conversation", and the cache
never returns one — it returns `null` (a miss) and the network is asked.
A miss is `before` **outside what the suffix covers continuously** — above
its newest `end`, or at or below its oldest `first`. This plan first said a
miss was "no chunk of the suffix ends at `before`", which is wrong in the
commonest case there is: a warm open draws the newest eighty lines of the
live run, so the cursor the reader then scrolls back from is in the *middle*
of a chunk. Under the narrower rule every warm open sent its first backwards
page to the server, and that page overlapped what the phone already held and
could not be stored, so the same history was fetched again on every visit.
The feature would have saved the opening window and nothing else.
The row rule is a copy of the server's, and copies drift. It is short, it is
pure, and it is under a JVM unit test with the same fixture as the server's
`coalescing_counts_rows_and_joins_delta_runs` — a run cut by the limit, a
`usageDelta` inside a run (the server flushes the run there, so it is two
rows), and a page that is all one run.
### 6. What a `reset` means for the cache: behind, not wrong
The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT` events
behind, then the newest 200 raw events. For the cache that means **the
history is intact and there is a gap**: the probe passed, the file is
append-only, and the window's first seq is above the open chunk's end. The
store learns this from the first window event's seq and needs no signal from
the screen; the gap is filled by paging.
The reset handler also clears `queued` and `waitingCommands`, which it did
not originally. Both are folded from events, and a `messageQueued` whose
resolving `userMessage` fell in the gap would otherwise draw a waiting bubble
for a message the session has long since read. That was a latent bug made
likely by the cache, because a cached tail is older than a fetched one.
`contextTokens` needs no clearing: `UsageDelta.context` is absolute, so the
window's first one corrects it.
### 7. Session state that is not the transcript comes from the list, not the cache
`apply` derives `status`, `model`, `permissionMode` and `compactingSince`
from `Status` and `Settings` events. Replayed from a fetched page those are
current; replayed from the cache they are as old as the last visit, while the
list row the reader just tapped was fetched moments ago. So the cache replay
runs through `apply` for the transcript's sake and then **reassigns those
four from `summary`**, which is the newer of the two measurements; the
stream's catch-up then makes them current. Without this a session that
finished an hour ago would open saying "working" until the stream connected,
which is a status row lying for a round trip.
### 8. Reload, in session settings
A row under the working directory showing what the button discards:
[ Transcript ] 2.3 MB cached [ Reload ]
The size is the unknown state made visible — `null` while the directory is
being measured (spinner, as the notifications switch does), "nothing cached"
when the directory is absent or empty, else the size. The caption is in the
style of Move's, because the button costs something the reader cannot see:
*"Reload throws away this phone's copy and fetches the transcript from the
server again. Use it when what is shown here disagrees with the file on the
machine."*
Pressing it purges the session's cache directory, closes the dialog, and
rebuilds the screen as a cold open, with the reader put back where they were.
The mechanism is an `epoch` counter in the key of the opening effect and the
stream effect; incrementing it cancels both and relaunches them. `savedAnchor`
is keyed on the epoch too, so the restore reads the anchor saved at the
reader's *current* position. The button is enabled whether or not anything is
cached: "what I see disagrees with the machine" is a state an empty cache can
also be in, and a control that comes and goes makes its own presence the
signal.
Nothing is announced on success — the transcript shows the opening spinner
and then the rows, which is what the screen already says about a reload. A
failure is the opening fetch's, and lands on the stream banner.
Rejected: a global "clear transcript cache" in the app's settings. Not asked
for; eviction bounds the total, and the per-session button is where the
reader is when they notice a problem. Easy to add as one more caller of
`purgeAll`.
### 9. Budget, eviction, pruning
Bounded three ways, each with its path out written beside the path in:
- **Budget.** `CACHE_BUDGET_BYTES` is 256 MB across all sessions of one
server. Each open touches the session directory's mtime; after the opening
replay, on `Dispatchers.IO`, the store sums the server's directories and
deletes least-recently-touched ones (never the one on screen) until under
budget. 256 MB is a dozen of the largest transcripts seen in this VM
(21 MB for 24,000 events) and a small fraction of a phone; it is a number
to revisit against real use, not a measurement.
- **Deleted sessions.** The list screen's delete purges after `deleteSession`
succeeds, and every successful list fetch calls `retainOnly(ids)`, so a
session deleted from another device is pruned on the next visit to the
list. `Drafts.kt` chose not to prune because its residue is bytes; here it
is megabytes.
- **Android.** `cacheDir` may be emptied at any moment, including while a
screen is open. Every read tolerates a missing directory and every write
failure is swallowed once.
### 10. The cache never breaks the screen
Every store operation that touches the disk catches `IOException` and answers
as if the cache were empty: `null` from a read, no-op from a write, logged
once. After a write failure the instance stops writing, so a full disk costs
one log line rather than one per delta. A line at the end of an open chunk
that does not parse — the app died mid-write — is dropped and the file
truncated to the last good line before anything is served from it; a line
that does not parse anywhere else purges the session's cache, since that file
was not written by this code. None of this is reported on screen: none of it
changes what the screen shows, and the reader has nothing to do about it.
## Layout on disk
<cacheDir>/transcripts/
v1/
10.0.2.2_8443/ one directory per server (host_port)
3f2c…/ one per session id
1-1650.rows.jsonl coalesced page: covers seqs 1..1649
1650-2001.rows.jsonl
2001-2400.raw.jsonl a closed live run
2600-open.raw.jsonl the live run
Here 2400..2599 is a gap: the reader was away for two hundred events and the
stream reset. The suffix is the single chunk `2600-open`; the first backwards
page asks the server for `before=2600&after=2399&coalesce=true`, and once a
page comes back with `first == 2400` the suffix runs to seq 1.
Each `.jsonl` is one JSON object per line, oldest first, exactly as the
server sent it. No header, no index: coverage is in the name, order is the
file's, and the seq is in every line.
## What building it changed
Each of these contradicted the plan, and each was found by running it rather
than by reading it. The decisions above are amended in place; this is what
moved, so a reader who remembers the first version knows what to re-read.
- **The probe's equality had a false premise** (decision 3). The server did
not hand out the same line twice the same way. Fixed on the server.
- **A cached page starts anywhere inside the run** (decision 5). Requiring a
chunk boundary would have made the cache save the opening window and
nothing else.
- **The opening window is stored by `append`, not by `storePage`.** The
sketch had `storePage` grow a special case for "this page is the new open
chunk", decided by an implicit condition a raw history page also satisfies.
Appending each line instead is the mechanism that already exists, and the
open chunk stays the one thing that grows.
- **Chunks are read backwards, in blocks, and never whole.** Every question
the cache is asked is about the newest end, and a live run reaches the size
of the conversation — so reading a chunk to answer with eighty lines of it
is the cost the server's own reader was rewritten to stop paying, arriving
on the phone. Damage is therefore noticed when a read reaches it rather
than up front, which is the better time: what is not read cannot be wrong.
- **The stream waits for the opening effect's probe.** The screen lifts
`ready` before the probe returns — that is the point of the cache — so
`ready` stopped being the whole gate, and the stream loop asked the same
question a second time and raced its own answer. Two probes per warm open,
visible in the server's log.
- **`SessionCache` is synchronized.** The stream appends live events from one
IO thread while a reader scrolling back reads pages from another; the open
chunk's name, its end and its writer must never be seen half-rotated.
## What it cost, measured
On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of 505
events (three short exchanges and two 300-delta replies):
- **Reopening it: one request, for one event.** The probe, and nothing else —
including scrolling the whole conversation back to its first line. A cold
open of the same session is two requests and 100 events.
- **A reset after falling 300 events behind costs the gap and no more.** The
window arrived at seq 306, the phone held up to 202, and the first
backwards page asked `before=306&after=201` and came back with **four
coalesced rows** covering 202..305 — against the 104 raw events an
unbounded page would have re-fetched and thrown away.
- **Every chunk is exactly what the server says for the range its name
claims**, checked line by line against `/transcript` for each chunk's own
`before`/`after`/`coalesce`, across a reset and a gap-fill.
- **Nothing about drawing changed**, which is what a cache must not do:
`transcript-bench.sh` before and after, same viewport content and gestures,
p50 16.9ms both times and the transcript's own draw accounting at 0.33ms
against 0.32ms.
Still to measure, in real use rather than here: the size the cache reaches
against `CACHE_BUDGET_BYTES`, and whether the probe's round trip is ever what
a reader waits on.
## Open questions
- **The probe on every reconnect, not only on open?** A file replaced *while*
the screen is open is not made worse than it was, but the server-side check
decision 3 rejects would close it. Decide after measuring how often the
probe's round trip is what the reader waits on.
- **Images.** `SessionImage` fetches bytes from the files route on draw; they
are not part of this cache and are re-downloaded per view. A separate,
simpler cache (a directory of refs, no ordering) if the measurement above
says the images are where the data goes.
-265
View File
@@ -1,265 +0,0 @@
# Transcript rendering: what was learned, and what is next
Written 2026-09-03 at the end of a week of work on the session screen's
transcript, so the next session can start from here rather than from a
compacted context. Work that is finished lives in "the architecture, as
built"; the running log of how each piece got there has been dropped. `AGENTS.md` holds the one-paragraph conventions; this is
the longer record: the measurements that drove each decision, the
techniques that worked, the ones that did not, and the order to do the rest
in. `PLAN.md` remains the design source of truth; nothing here contradicts
it.
## The goal, and where it stands
A reply of any length must scroll at the phone's 120Hz without a bump, and
must keep doing so while the reply is still streaming in. Measured on the
Pixel 9 Pro XL by Bryan, the transcript went from visible stalls at long
replies and at lists of links to "I have to actually try to feel any
bumps". The remaining work is finish and extensibility rather than
performance.
## The architecture, as built
Everything below lives under `app/androidApp/src/main/kotlin/com/example/aiapp/`.
**Rows become units, and units are bounded.** `TranscriptUnits.kt` turns a
transcript row into the things the lazy list actually holds. An assistant
reply is not one unit: it is one unit per piece of its markdown, so the
list composes and draws a paragraph, a fence, a table or one bullet at a
time. The reason is the draw phase: a row's display list holds every glyph
of it and is re-recorded whenever drawing is invalidated, and the lazy list
composes an item whole in the frame it scrolls into. The tallest single
row still being drawn before this was 36,982px, twenty-five screens in one
message. Long user messages are sliced the same way (`UserChunk`), through
the shared `cardPiece` modifier that draws one card in lazy-list pieces.
**One parse per message, addressed by piece.** `MarkdownPieces.kt`'s
`Piece(block, item)` is an address into the message's single parse tree,
not a substring: `block` indexes the root's children and `item` one
`LIST_ITEM` of a top-level list. Cutting was originally done by
re-parsing substrings, which cost a parse per piece and broke reference
links defined at the foot of a message. `ParsedReplies` caches the parse
and the piece list per text (`of`, `piecesOf`), warmed off the composing
thread by `TranscriptItems.warm`. The parser is still intellij-markdown via
the mikepenz renderer, but its `Markdown()` composable is not called at all:
`MarkdownRoot` in `Markdown.kt` provides the `Local*` environment itself --
reference links from the parse, padding, dimens, colours, typography, a
no-op image transformer, animations, components -- and `MarkdownElement`
dispatches a whole block through our component table. Nothing between a
piece and the screen is the library's now except the leaf composables that
table names.
**Lists are drawn an item at a time, by us.** The renderer has no element
for a single list item, so `MarkdownListItem` draws one: marker, then the
item's children, nested lists recursing through `MarkdownList`. The
marker is drawn in one place on purpose; styled bullets per depth go
there.
**Links are spans, not nodes.** `MarkdownLinks.kt`. Compose turns every
`LinkAnnotation` into a layout node (clipped, focusable, hoverable,
clickable, outline recomputed from the text layout). A paragraph of eight
links was nine nodes, and measured against the same paragraphs with each
link replaced by plain words it cost 26.3ms worst measure against 5.2ms,
1.7x the place time. That was the bump at a reply's list of sources.
`LinkedText` builds the annotated string with the renderer's own inline
builder but answers links itself: colour, underline, a string annotation
carrying the URL, and one tap detector for the whole text that asks the
layout which glyph is under the finger. Hit-testing must check the glyph
on either side of the returned caret, because `getOffsetForPosition`
returns the nearest boundary; taps on the right half of a glyph otherwise
open nothing. Headings need the `ATX_CONTENT`/`SETEXT_CONTENT` child, since
the inline builder draws nothing for a node type it does not know (a week
of blank headings). Tables go through `LinkedTable`/`LinkedTableRow` so
cells get the same treatment.
**Text draws on the platform directly.** A paragraph without an image
skips the renderer's `MarkdownText`, which charges every paragraph for the
possibility of inline images (placement callback, derived inline-content
map, semantics group, size animation). Paragraphs that contain an image
still take the renderer's path.
**Tables spread or scroll without subcomposition.** The renderer used
`BoxWithConstraints` to decide; `LinkedTable` uses
`fillMaxWidth().horizontalScroll().layout { }` -- `horizontalScroll`
passes `minWidth` through and lifts `maxWidth` to infinity, so the inner
layout reads `minWidth` as the room available and takes
`max(minWidth, columns * cellWidth)`.
**A streaming reply is reparsed one block at a time.** `LiveParse` in
`Markdown.kt` freezes every finished top-level block with its parse and
reparses only the tail block per delta. Markdown's block rules make later
text unable to alter an earlier block, with the single exception of a
late reference definition, which is accepted. Measured on a 58-word stream
of list, fence, table and quote: 47 tail reparses at 1.7ms mean. A
single-list stream would reparse the whole list per delta, since it is one
tail block; that is what the rule below cuts.
**A streaming list becomes a unit per item.** `LiveParse.advanceTo` cuts at
the last item of a multi-item list (`openPiece`), provided that item has
content beyond its marker -- a bare `-` is an empty item now and the first
character of a paragraph line once `-x` arrives, so cutting on it would draw
that line as a new item. The cut is at the start of the item's line, so the
indentation the reparse reads its nesting from survives. `Segment.continues`
marks a tail that carries on a list, and `MarkdownPiece`'s
`continuesList`/`listContinues` keep an inner item's padding at the seam, so
nothing moves when the seam does. Forty linked bullets streamed a word at a
time went from 2412ms of reparsing to 674ms, and `record: one block` from
1.8ms worst to 0.7ms.
**Fences are highlighted off the drawing thread, and a fence still being
written is drawn plain.** `Highlighter.kt` holds `highlight` and the scanner
behind it (shared with a tool call's input, so the same code is the same
colours wherever it appears); `CodeFence.kt` holds the `fenceLanguage` alias
table and `fenceContent`. A word not in the table stays plain, because a
fence coloured by the wrong language's rules looks highlighted and is wrong
in a way the reader cannot see. Highlighting is warmed and cached exactly as
parsing is (`ParsedReplies.highlighted`, filled by `warm` from
`fences(parse)`), and `highlight` takes no colour from the theme, which is
what lets it run off the drawing thread: a two-hundred-line Kotlin fence
costs 15ms to scan on the emulator's debug build -- it cost 102ms through
the library that used to do this -- and a `remember` inside the fence was
charged that again every time the block scrolled back into composition. Because the warming has to ask for the same
string the drawing does, `fenceContent` extracts the code and the language
word itself -- two extractions would be two keys, and the warmed answer
would be missed at every fence with nothing saying so. A fence still
arriving is the same stall in a second place, and warming cannot reach it:
the tail was re-lexed at every delta, on the composing thread, for colours
on text being replaced as fast as they were computed -- 211 lexes and 13.7
seconds across one turn. So `MarkdownRoot`'s `streaming`, true only for a
live reply's last segment, draws the block plain until it freezes; a
finished fence colours as soon as the next block starts, and the settling
lex happens once, in `warm`.
**Markers, and images.** `MarkdownListItem`'s `Marker` draws the bullet by
depth, cycling past the third, in `listMarkerColor` (Theme.kt). The colour
is the same at every depth on purpose: depth is said by the glyph and the
indent, and a colour per depth would make a difference in degree look like
one in kind. The app has no image loader and the renderer's transformer was
the no-op one, so an image in a reply drew as *nothing at all*; an `IMAGE`
node is now appended by `appendPlainLink` as a link carrying its alt text
(the address when there is none), which says what was there and opens it.
**Expansion anchors the edge that was tapped, and the list never moves
under the reader** except when pinned to the bottom with new content
arriving. Those two rules are in `ScrollAnchor.kt` and `TranscriptList.kt`
and are the reason several tempting simplifications were rejected.
## Techniques and harness
- **`app/ui-sandbox.sh`** starts a second `ai-server` against a sandbox
home with the echo driver, so nothing touches real sessions.
`spawn [title]` makes an echo session and prints its id; `send SID text`
or `send SID @file` sends into it; `api /path [curl args]` is an
authenticated request. Restarting it regenerates the config but keeps
enrolled tokens.
- **The echo driver is the test rig** (`server/src/session/echo.rs`, the
list at the top of the file). `/stream N`, `/mixed N`, `/table N`,
`/tools N gap`, `/ask`, `/peer`, `/compact`, `/slow`, `/bash command`
each produce a shape the real CLI produces only when it feels like it.
Build what a UI test needs into it rather than spending model turns.
- **`app/transcript-bench.sh`** is the standard measurement: restart, open
the first session, scroll, print the render report. The report is what
the "Copy render timings" button copies and also logs
(`adb logcat -d -s ai-app:I`), and it includes the last crash's stack
(`CrashLog.kt`), which is how a crash on the phone reaches a session
here.
- **`app/stream-bench.sh [-k] FILE`** is `transcript-bench.sh` for a reply
still arriving: opens the first session, taps "Jump to latest" so the list
is pinned to the newest end, resets the report, sends FILE, waits for the
transcript to stop growing, prints the report. Both of those last two are
corrections to a first version that measured nothing -- a transcript parked
further back never redraws while a reply streams into it, and a session is
idle at *both* ends of a turn, so polling for idle answers before the turn
has started. Fixtures live in `/tmp` and are regenerated from the shapes
named here: `fixture.md` (lists four deep, ordered and nested, fences in
kotlin/rust/sh/none, a table with a link, a quote with a list, an inline
and a standalone image, a reference link), `longfence.md` (200-line Kotlin
fence), `longlist.md` (40 linked items).
- **Two traps in the emulator loop**, each of which cost a bench run.
`adb shell pm clear` removes the enrolment and the notification permission
along with the saved anchors, so the next run measures a permission
dialog; re-enrol with the command `ui-sandbox.sh` prints and
`pm grant ... POST_NOTIFICATIONS`. And a saved anchor is per session id,
so the only way two builds start a scroll from the same place is a *fresh
session for each*.
- **`DebugStats`/`FrameStats`** time our own phases (`record: one block`,
`measure: the app root`) and count events (`markdown reparsed while
streaming`, `markdown cut into pieces`). Add a counter before guessing.
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
framework, via `atrace` text output, no trace processor needed. It is
how the link-node cost was attributed.
- **`app/debug-transcript.sh`** loads a real Claude Code conversation onto
the emulator; two faults were invisible on fixtures and obvious on it.
Real transcripts are private: fixtures stay in `/tmp`, never in the repo.
- **`ui-trace`** reads the screen as text. Bounds print as
`x1,y1..x2,y2`; unanchored `-m` patterns match labels, anchored ones do
not. A row taller than the viewport reports clipped bounds, so compare
screenshots for that case.
- **Emulator frame times are not app measurements.** Software rendering
puts the stock Settings app at 60ms of UI-thread traversal per frame.
Costs of operations in milliseconds rank correctly; smoothness itself is
judged on the phone.
- **System Tracing on the phone does not work on GrapheneOS.** Its
Categories list is empty because the tracing daemon builds it by running
`atrace --list_categories`, which returns nothing there, and a recorded
trace contains zero ftrace events: no app sections, no frames, no
scheduling. Callstack sampling records, but the app's profiler config
unwinds one process shard in four. GrapheneOS issues 2206 and 6094 are
open on exactly this. Until they close, phone numbers come from the
render report and from Bryan noticing.
- **Compose `DropdownMenu` in an edge-to-edge activity** needs
`PopupProperties(clippingEnabled = false)` or it opens a status bar's
height away from its anchor (`~/.claude/TOOLCHAIN.md`).
- **The syntax highlighter is ours: `Highlighter.kt` and `Languages.kt`.**
One left-to-right scanner with a small state -- in a line comment, in a
block comment, in a string, or in ordinary code -- and a `Rules` row per
language, so a new language is a table entry rather than code. Every span
is emitted by advancing an index, so spans cannot overlap, arrive out of
order or run backwards, and an unterminated string or comment simply runs
to the end of the code. `HighlighterTest.kt` is the JVM unit test
(`./gradlew :androidApp:testDebugUnitTest`); the cases in it are the
library's mistakes, kept as regressions.
It replaced dev.snipme:highlights 1.1.0 on 2026-09-03, which found
comments before it knew the language and paired `/*` with `*/` by
ordinal. That library used one set of delimiters for every language, so
`//` in any URL commented out the rest of its line (in `curl
https://example.com/x && echo done` the comment ran to the end and took
`echo` with it, and in Kotlin `val url = "https://..."` the string
disappeared inside it), every Rust `#[derive(...)]` greyed out as a
comment, a `#` inside a Kotlin string swallowed the line, and `x '*/a/*'`
in shell yielded `start=6, end=5` -- a range `AnnotatedString` rejects,
which crashed a card holding `-path '*/.git/*'`. Comments were located
before strings and won over them, so post-processing could not recover
what a wrong comment range had already suppressed. The scanner is also
about seven times faster on the same fixture, and it colours RON, TOML,
fish and JSON, which the library did not know at all.
## Rejected, and why
- **Writing our own markdown renderer.** Rejected in favour of keeping
the intellij-markdown parser and the library's inline builder while
owning block dispatch and the leaf composables. The parser is the hard
part and is not the slow part; everything that was slow lived in the
composables, which are now ours.
- **Re-parsing substrings per piece.** Cost a parse per piece and broke
foot-of-message reference links. Replaced by addressed pieces of one
parse.
- **Animated or timing-dependent corrections.** Anything the reader could
catch at 120Hz is a bug; corrections must be structurally impossible to
see.
## What is next, in order
1. **The reconnect loop.** Restarting the app onto a session with a saved
anchor while a long reply was streaming left it reconnecting every 1.5s
(`RECONNECT_DELAY_MS`), spinner up, until the server was restarted.
`events?after=N` more than `CATCH_UP_LIMIT` (200) behind answers `reset`
plus the newest 200 *raw* deltas -- a window starting mid-message -- and
the reset clears `items`, which is the state the restore loop then pages
against. The restore's one-event-per-request bug was part of what made it
so visible and has been fixed; whether this survives that fix is the
first thing to find out.
2. **Regression runs.** `transcript-bench.sh` and `stream-bench.sh` before
and after any change to the files above, with the report in the commit.
The numbers to watch are the worst `record: one block`, the reparse mean
while streaming, and the draw phase's accounting line.
+1081
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "android-shell"
version = "0.1.0"
edition = "2024"
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
# why those two classes cannot be anything but Java/Kotlin, registered from
# the manifest by name). Everything they would otherwise have done in
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
# picking a session for a share -- is here instead, built on `client-core`
# so the networking and parsing are not duplicated a third time next to the
# server and the Kotlin app.
#
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
# on a normal host target without an Android NDK toolchain, the same
# posture `client-core` and `server` already have.
[lib]
name = "android_shell"
crate-type = ["cdylib", "lib"]
[dependencies]
client-core = { path = "../client-core" }
jni = "0.22"
log = "0.4"
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
# without a backend installed -- so without this, every recoverable error
# at a native entry point would be silently dropped rather than reaching
# logcat. Android-only: nothing else here needs it, and it does not build
# off-device (see `notify::ensure_logger`'s call site, the only place this
# is used).
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.15"
+152
View File
@@ -0,0 +1,152 @@
//! Thin wrappers around the five `Env` calls this crate makes constantly
//! (a class name, a method name and a signature, all as plain `&str`).
//!
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
//! here uniformly rather than switching to the compile-time `jni_str!`
//! literal macro call by call -- these are a handful of short, one-off
//! lookups, not a hot loop, so the difference is not worth two code paths
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
//! `FieldSignature`, which is why those go through
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
//! parsed form is what lets these calls skip re-validating the signature
//! against the arguments on every call, which is the whole reason `jni`
//! moved to it.
//!
//! **The classloader gotcha, found by testing (2026-09-05).** A class
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
//! `get_static_field` -- anything that resolves a *class*, as opposed to
//! `call_method` on an object it already has, which needs no such lookup)
//! defaults to `FindClass`'s ordinary search when it cannot find the
//! calling thread a classloader through `Thread.getContextClassLoader()`.
//! That default is fine on a thread the JVM itself started -- an
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
//! from `android-shell`'s own background thread (the notification
//! follow-loop, the share upload) is running on a thread *Rust* spawned
//! and attached with `JavaVM::attach_current_thread`, which the platform
//! never gave an app classloader. Framework classes
//! (`android.app.Notification$Builder`, ...) still resolve, because they
//! are reachable from the bootstrap loader `FindClass` falls back to --
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
//! packaged inside this app's own APK. The failure was
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
//! as "failed to resolve Java class ... (class not found or linkage
//! error)" -- on a real device this reads as "the notification silently
//! never arrives," since the whole call is inside the follow loop and the
//! ongoing foreground notification (built on the main thread, in
//! `try_start`, before the background thread exists) posts fine either
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
//! first time any entry point has a `Context` to ask, and every class
//! lookup below goes through it explicitly via `LoaderContext::Loader`
//! rather than the thread-dependent default -- so it is correct on the
//! main thread and on this crate's own background threads alike.
use jni::Env;
use jni::errors::Result;
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
use jni::refs::{Global, LoaderContext};
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
use jni::strings::JNIString;
use std::sync::OnceLock;
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
/// Caches `context`'s own `ClassLoader`, the first time this is called.
/// Cheap to call from every entry point that has a `Context` on hand
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
/// a `OnceLock::get` and nothing else.
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
if CLASS_LOADER.get().is_some() {
return Ok(());
}
// context.getClass().getClassLoader() -- resolved via `call_method` on
// real objects throughout, so this needs no class-name lookup of its
// own and has nothing to bootstrap.
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
let loader_obj = call_method(
env,
&class_obj,
"getClassLoader",
"()Ljava/lang/ClassLoader;",
&[],
)?
.l()?;
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
let global = env.new_global_ref(&loader)?;
// Lost the race with another entry point calling this concurrently --
// both loaders name the same app, so either one is fine and there is
// nothing to reconcile.
let _ = CLASS_LOADER.set(global);
Ok(())
}
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
/// through the cached app classloader when one has been remembered, and
/// through the ordinary default otherwise -- which is every call made
/// before any entry point has run, and is also correct for a main-thread
/// caller, so there is no case this makes worse.
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
match CLASS_LOADER.get() {
Some(loader) => {
let binary_name = name.replace('/', ".");
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
}
None => env.find_class(JNIString::new(name)),
}
}
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
resolve_class(env, name)
}
/// A new Java string as a plain `JObject` -- what every call site here
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
/// `JString: Into<JObject>` is the documented way across).
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
Ok(env.new_string(text)?.into())
}
pub fn new_object<'local>(
env: &mut Env<'local>,
class: &str,
sig: &str,
args: &[JValue],
) -> Result<JObject<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.new_object(class, sig.method_signature(), args)
}
pub fn call_method<'local>(
env: &mut Env<'local>,
obj: &JObject,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
}
pub fn call_static_method<'local>(
env: &mut Env<'local>,
class: &str,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
}
pub fn get_static_field<'local>(
env: &mut Env<'local>,
class: &str,
field: &str,
sig: &str,
) -> Result<JValueOwned<'local>> {
let sig = RuntimeFieldSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.get_static_field(class, JNIString::new(field), sig.field_signature())
}
+131
View File
@@ -0,0 +1,131 @@
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
//! package comment for what this crate is and RUST.md's E3 entry for the
//! design decisions.
//!
//! Each native method is declared with `jni`'s [`native_method!`] macro
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
//! the macro derives the mangled export name and the JNI signature from the
//! Rust function itself, so the two cannot drift apart the way a
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
//! routinely do. `error_policy = LogErrorAndDefault` matches
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
//! JNI call that threw) is reported to logcat, not thrown back into Java
//! as an exception that would crash the app over something recoverable.
//!
//! Each `const _: NativeMethod = native_method! { ... };` binding is
//! otherwise unused by name -- `_` is the idiomatic way to keep a
//! side-effecting const (here, generating the `#[export_name]`d function
//! the JVM resolves by the JNI naming convention) without a `dead_code`
//! warning for a binding nothing reads.
mod jcall;
mod notify;
mod settings;
mod share;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JClass, JObject};
use jni::sys::jint;
use jni::{Env, NativeMethod, native_method};
/// Installs the `log` backend that routes to logcat, once per process.
/// Without it, `LogErrorAndDefault` (every native method below) and any
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
/// call into the `log` facade's default no-op logger, and a real failure
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
/// crashing, since nothing on screen or in the log says a notification was
/// dropped. Called from every entry point below rather than a Java-side
/// `Application.onCreate`, since this crate deliberately has no such class
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
fn ensure_logger() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
#[cfg(target_os = "android")]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("android-shell"),
);
});
}
// The parameters are spelled as their Java types, not as `JObject`: the
// macro encodes each argument into the exported symbol's JNI signature
// (and JNI resolves `Java_...` names *by* that signature), so a generic
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
// method actually declared `(Landroid/app/Activity;...)` -- two different
// symbols that never resolve to each other, silently, with no compiler
// error on either side. `android.app.Activity` etc. have no dedicated
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
// implementation functions below (the "Built-in Types" note in
// `native_method!`'s docs).
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.MainActivity",
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
error_policy = LogErrorAndDefault,
};
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
fn native_handle_intent<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
activity: JObject<'local>,
intent: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &activity)?;
share::handle_intent(env, &activity, &intent)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_sync(context: android.content.Context) -> (),
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
/// enrollment may have just landed) and from `NotificationService.sync`
/// itself. See `notify::sync`.
fn native_sync<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
context: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &context)?;
notify::sync(env, &context)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_start_command(service: android.app.Service) -> jint,
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
fn native_on_start_command<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
service: JObject<'local>,
) -> Result<jint, jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &service)?;
Ok(notify::on_start_command(env, service))
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_destroy() -> (),
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
fn native_on_destroy<'local>(
_env: &mut Env<'local>,
_class: JClass<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
notify::on_destroy();
Ok(())
}
+556
View File
@@ -0,0 +1,556 @@
//! Where a notification is said, and the foreground service that keeps
//! the connection open while the app is closed. Ported from
//! `Notifications.kt`'s `NotificationService`, minus the "session on
//! screen" / "hand to the app as a banner" branches: those read
//! process-wide state that only exists because a screen is drawn to
//! register against, and this experiment draws no screen yet (that is
//! E4's job, on iris). So every notification here takes the third branch
//! Kotlin's `show` already had -- the platform's own drawer -- which is
//! also exactly the case E3's pass condition asks for: **a notification
//! arrives with the app closed.**
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use client_core::api::UreqTransport;
use client_core::notifications::{SessionNotification, follow_notifications};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JValue};
use jni::sys::{JNI_TRUE, jint};
use crate::settings::{self, ServerSettings};
const ALERT_CHANNEL: &str = "sessions";
const ONGOING_CHANNEL: &str = "connection";
const ONGOING_ID: i32 = 1;
const ALERT_ID: i32 = 2;
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
/// Whether the follow-loop thread is already running. **A deviation from
/// `Notifications.kt`, found by testing rather than planned**: the Kotlin
/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }`
/// on *every* call, with nothing to notice a previous one is still going --
/// and `sync()` calling `startForegroundService` when the service is
/// already running is an ordinary Android start, not a restart, so
/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls
/// `sync` once itself, then again inside `handle_enrollment` after saving
/// the token) hits exactly this path and was observed opening **two**
/// concurrent connections to `/notifications` from one process -- caught
/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached
/// thread ai-app-notifications` lines for one enrollment. Guarded here
/// rather than left to match Kotlin's behaviour exactly, since duplicating
/// a live connection is a resource leak with no upside; worth carrying the
/// same guard back to `Notifications.kt` separately.
static RUNNING: AtomicBool = AtomicBool::new(false);
/// Set by `nativeOnDestroy`, checked by the follow loop between
/// reconnects. **Known gap, recorded rather than hidden**: unlike
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
/// can interrupt a `ureq` read already blocked inside one connection --
/// `Transport::stream` hands back a plain `Read` with no cancellation
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
/// practice this is a bounded wait rather than a hang; closing that gap
/// for real means adding a cancellation point to `client_core::Transport`,
/// which is a decision affecting every caller of that trait, not just this
/// one -- left for whoever next depends on prompt shutdown.
static STOPPING: AtomicBool = AtomicBool::new(false);
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
crate::jcall::get_static_field(env, class, field, "I")?.i()
}
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
crate::jcall::call_static_method(
env,
"androidx/core/app/NotificationManagerCompat",
"from",
"(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;",
&[JValue::Object(context)],
)?
.l()
}
fn create_channel(
env: &mut Env,
manager: &JObject,
id: &str,
name: &str,
importance: i32,
) -> Result<()> {
let id_j = crate::jcall::jstr_obj(env, id)?;
let builder = crate::jcall::new_object(
env,
"androidx/core/app/NotificationChannelCompat$Builder",
"(Ljava/lang/String;I)V",
&[JValue::Object(&id_j), JValue::Int(importance)],
)?;
let name_j = crate::jcall::jstr_obj(env, name)?;
crate::jcall::call_method(
env,
&builder,
"setName",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
&[JValue::Object(&name_j)],
)?;
let channel = crate::jcall::call_method(
env,
&builder,
"build",
"()Landroidx/core/app/NotificationChannelCompat;",
&[],
)?
.l()?;
crate::jcall::call_method(
env,
manager,
"createNotificationChannel",
"(Landroidx/core/app/NotificationChannelCompat;)V",
&[JValue::Object(&channel)],
)?;
Ok(())
}
/// Two channels, because they are two different things to be told -- see
/// `Notifications.kt`'s `createChannels` for the reasoning; the names and
/// importances here are copied from it exactly, since a phone that has
/// seen both apps should not learn two different vocabularies for the
/// same fact.
fn create_channels(env: &mut Env, context: &JObject) -> Result<()> {
let manager = notification_manager(env, context)?;
let default = static_int(
env,
"androidx/core/app/NotificationManagerCompat",
"IMPORTANCE_DEFAULT",
)?;
let min = static_int(
env,
"androidx/core/app/NotificationManagerCompat",
"IMPORTANCE_MIN",
)?;
create_channel(
env,
&manager,
ALERT_CHANNEL,
"Sessions needing attention",
default,
)?;
create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?;
Ok(())
}
fn new_intent_for<'l>(
env: &mut Env<'l>,
context: &JObject,
class_name: &str,
) -> Result<JObject<'l>> {
let target_class = crate::jcall::find_class(env, class_name)?;
crate::jcall::new_object(
env,
"android/content/Intent",
"(Landroid/content/Context;Ljava/lang/Class;)V",
&[JValue::Object(context), JValue::Object(&target_class)],
)
}
/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s
/// `sessionIntent`, including building the URI through `Uri.Builder`
/// rather than string concatenation, for the same reason: an id needing
/// escaping must survive the round trip.
fn session_intent<'l>(
env: &mut Env<'l>,
context: &JObject,
session_id: &str,
) -> Result<JObject<'l>> {
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
crate::jcall::call_method(
env,
&intent,
"setAction",
"(Ljava/lang/String;)Landroid/content/Intent;",
&[JValue::Object(&action_view)],
)?;
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
crate::jcall::call_method(
env,
&builder,
"scheme",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&scheme)],
)?;
let authority = crate::jcall::jstr_obj(env, "session")?;
crate::jcall::call_method(
env,
&builder,
"authority",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&authority)],
)?;
let path = crate::jcall::jstr_obj(env, session_id)?;
crate::jcall::call_method(
env,
&builder,
"appendPath",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&path)],
)?;
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
crate::jcall::call_method(
env,
&intent,
"setData",
"(Landroid/net/Uri;)Landroid/content/Intent;",
&[JValue::Object(&uri)],
)?;
Ok(intent)
}
fn pending_activity<'l>(
env: &mut Env<'l>,
context: &JObject,
intent: &JObject,
) -> Result<JObject<'l>> {
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
crate::jcall::call_static_method(
env,
"android/app/PendingIntent",
"getActivity",
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
&[
JValue::Object(context),
JValue::Int(0),
JValue::Object(intent),
JValue::Int(update_current | immutable),
],
)?
.l()
}
fn builder_call<'l>(
env: &mut Env<'l>,
builder: &JObject<'l>,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<()> {
crate::jcall::call_method(env, builder, method, sig, args)?;
Ok(())
}
/// The type Android 14+ requires a foreground service to declare, and
/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`.
fn foreground_type(env: &mut Env) -> Result<i32> {
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?;
if sdk >= upside_down_cake {
static_int(
env,
"android/content/pm/ServiceInfo",
"FOREGROUND_SERVICE_TYPE_SPECIAL_USE",
)
} else {
Ok(0)
}
}
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
let builder = crate::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
builder_call(
env,
&builder,
"setContentTitle",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?;
builder_call(
env,
&builder,
"setSmallIcon",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(icon)],
)?;
builder_call(
env,
&builder,
"setOngoing",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?;
builder_call(
env,
&builder,
"setPriority",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(priority_min)],
)?;
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l()
}
/// Starts the service if there is a server to connect to, and stops it
/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`.
pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
let service_intent =
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
if settings::load(env, context)?.is_none() {
crate::jcall::call_method(
env,
context,
"stopService",
"(Landroid/content/Intent;)Z",
&[JValue::Object(&service_intent)],
)?;
return Ok(());
}
create_channels(env, context)?;
crate::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"startForegroundService",
"(Landroid/content/Context;Landroid/content/Intent;)V",
&[JValue::Object(context), JValue::Object(&service_intent)],
)?;
Ok(())
}
/// The `Service.onStartCommand` body -- loads settings, starts the
/// foreground notification, and spawns the follow-loop thread. Answers the
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
/// framework rather than hardcoded so a wrong guess at their values cannot
/// silently pick the other behaviour.
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
match try_start(env, &service) {
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
Ok(false) => {
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
}
Err(e) => {
log_error(env, "onStartCommand", &e);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
}
}
}
fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
let Some(settings) = settings::load(env, service)? else {
return Ok(false);
};
let ca = settings::load_pinned_ca(env)?;
let notification = ongoing_notification(env, service)?;
let fg_type = foreground_type(env)?;
crate::jcall::call_static_method(
env,
"androidx/core/app/ServiceCompat",
"startForeground",
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
&[
JValue::Object(service),
JValue::Int(ONGOING_ID),
JValue::Object(&notification),
JValue::Int(fg_type),
],
)?;
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
// the first is still going -- the ordinary case for this service,
// since `sync()` is called from more than one place -- must not open
// a second connection.
if RUNNING.swap(true, Ordering::SeqCst) {
return Ok(true);
}
let vm = env.get_java_vm()?;
let context = env.new_global_ref(service)?;
STOPPING.store(false, Ordering::SeqCst);
std::thread::Builder::new()
.name("ai-app-notifications".to_string())
.spawn(move || {
// Requests a *permanent* attachment (detached only when this thread
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
// this is the long-lived follow loop, not a one-shot callback.
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
follow_loop(env, &context, settings, &ca);
Ok(())
});
})
.ok();
Ok(true)
}
/// Follows the backend's notification stream, reconnecting until stopped
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
/// ordinary case, so it retries quietly and forever; nothing is shown when
/// it cannot connect, for the same reason as the Kotlin original: a
/// notification saying "I could not tell you whether anything happened" is
/// noise about a condition nobody can act on.
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
while !STOPPING.load(Ordering::SeqCst) {
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
let _ = follow_notifications(&transport, |notification| {
if let Err(e) = show(env, context, &notification) {
log_error(env, "show", &e);
}
!STOPPING.load(Ordering::SeqCst)
});
}
if STOPPING.load(Ordering::SeqCst) {
return;
}
std::thread::sleep(RECONNECT_DELAY);
}
}
/// One notification per session, replacing that session's previous one --
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
/// branches this module's doc comment explains.
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
let manager = notification_manager(env, context)?;
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
let allowed = if sdk < tiramisu {
true
} else {
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
let granted = static_int(
env,
"android/content/pm/PackageManager",
"PERMISSION_GRANTED",
)?;
let result = crate::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"checkSelfPermission",
"(Landroid/content/Context;Ljava/lang/String;)I",
&[JValue::Object(context), JValue::Object(&permission)],
)?
.i()?;
result == granted
};
let enabled =
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
if !allowed || !enabled {
return Ok(());
}
let intent = session_intent(env, context, &notification.session_id)?;
let pending = pending_activity(env, context, &intent)?;
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
let builder = crate::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::jcall::jstr_obj(env, &notification.title)?;
builder_call(
env,
&builder,
"setContentTitle",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?;
builder_call(
env,
&builder,
"setContentText",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&text)],
)?;
let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?;
builder_call(
env,
&builder,
"setSmallIcon",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(icon)],
)?;
builder_call(
env,
&builder,
"setContentIntent",
"(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&pending)],
)?;
builder_call(
env,
&builder,
"setAutoCancel",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let when = (notification.at * 1000.0) as i64;
builder_call(
env,
&builder,
"setWhen",
"(J)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Long(when)],
)?;
builder_call(
env,
&builder,
"setShowWhen",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let built =
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
.l()?;
let tag = crate::jcall::jstr_obj(env, &notification.session_id)?;
crate::jcall::call_method(
env,
&manager,
"notify",
"(Ljava/lang/String;ILandroid/app/Notification;)V",
&[
JValue::Object(&tag),
JValue::Int(ALERT_ID),
JValue::Object(&built),
],
)?;
Ok(())
}
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
/// the gap this module's `STOPPING` doc explains.
pub fn on_destroy() {
STOPPING.store(true, Ordering::SeqCst);
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
// comment): the old thread may still be inside a blocked read when a
// new `onStartCommand` follows immediately, which would spawn a
// second one before the first has actually stopped. Narrower than not
// resetting at all -- a service destroyed and never restarted would
// otherwise wedge `RUNNING` true forever -- and no worse than the
// known gap already accepted above.
RUNNING.store(false, Ordering::SeqCst);
}
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
let message = format!("android-shell: {where_}: {error}");
let _ = (|| -> Result<()> {
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
let msg = crate::jcall::jstr_obj(env, &message)?;
crate::jcall::call_static_method(
env,
"android/util/Log",
"e",
"(Ljava/lang/String;Ljava/lang/String;)I",
&[JValue::Object(&tag), JValue::Object(&msg)],
)?;
Ok(())
})();
}
+141
View File
@@ -0,0 +1,141 @@
//! Enrollment: where the backend is, and the Keystore-sealed token to
//! reach it. This crate does not reimplement the Android Keystore AES-GCM
//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin
//! class the production app already uses (see `ServerConfig.kt`), through
//! JNI, for two reasons: that code is shared with Dev Updater and already
//! tested, and the sealed value on a real phone is keyed to the exact
//! Keystore alias that class already uses -- reimplementing the crypto
//! here would either duplicate it or invalidate an existing enrollment.
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
/// Where the backend is and how to authenticate to it -- the Rust twin of
/// `wg-app-link`'s `ServerSettings` data class, read back field by field
/// rather than kept as a live JNI reference, so it can cross a thread
/// boundary (a `JObject` is tied to one `Env`/thread).
#[derive(Debug, Clone)]
pub struct ServerSettings {
pub host: String,
pub port: i32,
pub token: String,
}
impl ServerSettings {
pub fn base_url(&self) -> String {
format!("https://{}:{}", self.host, self.port)
}
}
/// This experiment's own scheme and Keystore alias -- distinct from the
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
/// installed side by side on the same development device without
/// colliding over which one a scanned QR or a deep link resolves to. See
/// RUST.md's E3 entry for why they are not the same value.
pub(crate) const SCHEME: &str = "aiappshell";
const KEY_ALIAS: &str = "aiapp-shell-token-key";
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
crate::jcall::new_object(
env,
STORE_CLASS,
"(Ljava/lang/String;Ljava/lang/String;)V",
&[JValue::Object(&scheme), JValue::Object(&alias)],
)
}
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
let host = get_string(env, settings_obj, "getHost")?;
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
let token = get_string(env, settings_obj, "getToken")?;
Ok(ServerSettings { host, port, token })
}
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
let jstr: JString = env.cast_local::<JString>(value)?;
jstr.try_to_string(env)
}
/// The stored enrollment, or `None` when there is not one -- mirrors
/// `ServerConfig.kt`'s `loadServerSettings`.
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::jcall::call_method(
env,
&store,
"load",
"(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;",
&[JValue::Object(context)],
)?
.l()?;
if settings_obj.is_null() {
return Ok(None);
}
Ok(Some(read_settings(env, &settings_obj)?))
}
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
let store = new_store(env)?;
let host = crate::jcall::jstr_obj(env, &settings.host)?;
let token = crate::jcall::jstr_obj(env, &settings.token)?;
let settings_obj = crate::jcall::new_object(
env,
SETTINGS_CLASS,
"(Ljava/lang/String;ILjava/lang/String;)V",
&[
JValue::Object(&host),
JValue::Int(settings.port),
JValue::Object(&token),
],
)?;
crate::jcall::call_method(
env,
&store,
"save",
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
&[JValue::Object(context), JValue::Object(&settings_obj)],
)?;
Ok(())
}
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
/// query-parameter rules rather than re-deriving them here.
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::jcall::call_method(
env,
&store,
"parseEnrollmentUri",
"(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;",
&[JValue::Object(uri)],
)?
.l()?;
if settings_obj.is_null() {
return Ok(None);
}
Ok(Some(read_settings(env, &settings_obj)?))
}
/// The CA this build pins, generated at build time the same way
/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`)
/// but into a plain Java constant, since this module has no Kotlin of its
/// own to generate into.
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
let value = crate::jcall::get_static_field(
env,
"com/example/aiapp/shell/PinnedCa",
"PINNED_CA_PEM",
"Ljava/lang/String;",
)?
.l()?;
let jstr: JString = env.cast_local::<JString>(value)?;
Ok(jstr.try_to_string(env)?.into_bytes())
}
+183
View File
@@ -0,0 +1,183 @@
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
//!
//! **Scope cut, recorded rather than silent**: only shared *text*
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
//! ported here, because `client-core`'s `ApiClient` does not have the
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
//! `content://` stream and no text falls through to a toast saying so,
//! rather than silently doing nothing. Closing this gap is the same
//! `client-core` work whichever caller needs it next.
//!
//! **Which session a share lands in** is also a placeholder: with no
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
//! to whichever session has the latest `last_activity` -- the one most
//! likely to be what somebody meant. Worth revisiting once a real screen
//! exists to ask instead of guessing.
use client_core::api::{ApiClient, UreqTransport};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
use crate::notify;
use crate::settings;
const ACTION_SEND: &str = "android.intent.action.SEND";
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
const ACTION_VIEW: &str = "android.intent.action.VIEW";
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
if value.is_null() {
return Ok(None);
}
let jstr: JString = env.cast_local::<JString>(value)?;
Ok(Some(jstr.try_to_string(env)?))
}
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
let message = crate::jcall::jstr_obj(env, message)?;
crate::jcall::call_static_method(
env,
"com/example/aiapp/shell/MainActivity",
"toast",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&message)],
)?;
Ok(())
}
/// The one place an incoming intent is sorted into what it means -- mirrors
/// `MainActivity.kt`'s `handleIntent`.
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let action = get_string_method(env, intent, "getAction")?;
if matches!(
action.as_deref(),
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
) {
return handle_share(env, activity, intent);
}
if action.as_deref() != Some(ACTION_VIEW) {
return Ok(());
}
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
if uri.is_null() {
return Ok(());
}
let scheme = get_string_method(env, &uri, "getScheme")?;
if scheme.as_deref() != Some(settings::SCHEME) {
return Ok(());
}
match get_string_method(env, &uri, "getHost")?.as_deref() {
Some("session") => handle_session_open(env, activity, &uri),
Some("enroll") => handle_enrollment(env, activity, &uri),
_ => Ok(()),
}
}
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
return Ok(());
};
// There is no session screen yet (E4's job); the toast is this
// experiment's stand-in proof that the tap was routed to the right
// session id.
toast(env, activity, &format!("Opened session {session_id}"))
}
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
match settings::parse_enrollment_uri(env, uri)? {
Some(parsed) => {
settings::save(env, activity, &parsed)?;
notify::sync(env, activity)?;
toast(
env,
activity,
&format!("Enrolled with {}", parsed.base_url()),
)
}
None => toast(env, activity, "Not a valid enrollment code"),
}
}
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
/// what happens to it, minus attachments per this module's doc comment.
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
let text = crate::jcall::call_method(
env,
intent,
"getStringExtra",
"(Ljava/lang/String;)Ljava/lang/String;",
&[JValue::Object(&extra_text)],
)?
.l()?;
let text = if text.is_null() {
None
} else {
let jstr: JString = env.cast_local::<JString>(text)?;
Some(jstr.try_to_string(env)?)
};
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
return toast(
env,
activity,
"Nothing to share -- only shared text is supported so far",
);
};
// Network I/O must not run on the calling thread: `handle_intent` is
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
// blocking socket read there is a `NetworkOnMainThreadException`. So
// the actual send happens on a JNI-attached background thread, the
// same shape `notify::try_start`'s follow loop uses; `toast` from that
// thread is safe because `MainActivity.toast` itself hops back to the
// main looper (see that method).
let vm = env.get_java_vm()?;
let activity_ref = env.new_global_ref(activity)?;
std::thread::spawn(move || {
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
share_in_background(env, &activity_ref, text);
Ok(())
});
});
Ok(())
}
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
let outcome = attach_to_a_session(env, activity, &text);
let message = match outcome {
Ok(title) => format!("Shared into \"{title}\""),
Err(message) => message,
};
let _ = toast(env, activity, &message);
}
fn attach_to_a_session(
env: &mut Env,
activity: &JObject,
text: &str,
) -> std::result::Result<String, String> {
let settings = settings::load(env, activity)
.map_err(|e| e.to_string())?
.ok_or_else(|| "Not enrolled yet".to_string())?;
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
.map_err(|e| e.to_string())?;
let client = ApiClient::new(transport);
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
let target = sessions
.into_iter()
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
.ok_or_else(|| "No session to share into".to_string())?;
client
.send_message(&target.id, text, &[])
.map_err(|e| e.to_string())?;
Ok(target.title)
}
@@ -13,9 +13,8 @@ import androidx.compose.ui.text.style.TextDecoration
*
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
* highlighter's colours are this app's reading of somebody else's code. They come out of the same
* Catppuccin values (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from
* somewhere else, but the two are not one table and must not become one -- adding a syntax role to
* this list would silently move `ls`'s directory blue.
* Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one
* table -- adding a syntax role to this list would silently move `ls`'s directory blue.
*/
data class AnsiPalette(
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
@@ -30,19 +29,18 @@ data class AnsiPalette(
* What a tool printed, with its terminal styling applied and everything else taken out.
*
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
* verbatim those are line noise in the middle of the thing being read: `ESC[0;32m` in front of
* every green word. Stripping them all would be the other half-answer -- colour is often the whole
* of what a diff, a test run or a linter is saying.
* verbatim those are line noise in the middle of the thing being read. Stripping them all would be
* the other half-answer -- colour is often the whole of what a diff or a test run is saying.
*
* So the sequences that decide how text *looks* become spans, and every other one is dropped.
* Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript
* is a scrolling document, and "go to column 40" has no meaning here that is better than nothing.
* So the sequences that decide how text *looks* become spans, and every other one is dropped rather
* than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no
* meaning in a scrolling document.
*
* A carriage return is honoured the way a terminal honours it: what was written since the last line
* break is thrown away and the line starts again. That is what makes a progress bar show its final
* state rather than every state it passed through, which was tens of lines run together.
* state rather than every state it passed through.
*
* Not a composable, and the palette is a parameter: this can then be remembered against the text it
* Not a composable, and the palette is a parameter, so this can be remembered against the text it
* parsed rather than re-run on every recomposition of the card holding it.
*/
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
@@ -71,10 +69,9 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
if (final == 'm') sgr = sgr.apply(params, palette)
}
}
// A bare carriage return rewrites the line. One before a newline is the other half
// of a Windows line ending: it rewrites nothing, and it is dropped rather than kept,
// since that pair is one line break and the return itself would draw as a stray
// control character.
// A bare carriage return rewrites the line. One before a newline is the other half of a
// Windows line ending: it rewrites nothing, and it is dropped rather than kept, since
// that pair is one line break.
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
flush()
dropLine(runs)
@@ -82,8 +79,8 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
}
c == '\r' -> at++
// Everything printable, plus the two control characters that are layout rather than
// terminal commands. A stray bell or backspace goes for the same reason a cursor
// move does.
// terminal commands. A stray bell or backspace goes for the same reason a cursor move
// does.
c >= ' ' || c == '\n' || c == '\t' -> {
plain.append(c)
at++
@@ -129,9 +126,8 @@ private const val BELL = '\u0007'
* Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte.
*
* One reader for every kind, because the point is to *leave* them all behind: a sequence this did
* not recognise would otherwise have its body printed as ordinary text, which is worse than the
* escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes
* (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones.
* not recognise would otherwise have its body printed as ordinary text. Three shapes -- the CSI
* (`ESC [ … letter`), the string escapes which run to a terminator, and the two-character ones.
*/
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
val next = text.getOrNull(at + 1) ?: return at + 1
@@ -140,9 +136,9 @@ private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Un
var end = at + 2
while (end < text.length && text[end] !in CSI_FINAL) end++
if (end >= text.length) {
// Cut off mid-sequence, which is what a stream that has not finished arriving
// looks like: drop the fragment rather than printing it, and the whole sequence
// arrives with the next delta.
// Cut off mid-sequence, which is what a stream that has not finished arriving looks
// like: drop the fragment rather than printing it, and the whole sequence arrives
// with the next delta.
text.length
} else {
onCsi(text.substring(at + 2, end), text[end])
@@ -6,26 +6,34 @@ import java.net.URL
import org.json.JSONArray
import org.json.JSONObject
// The REST half of the backend's surface (see server/src/routes.rs for the
// table); the SSE half is EventStream.kt. All blocking network calls --
// invoke from a background dispatcher. Each throws ApiException on failure,
// carrying the server's own explanation where it sent one, since those
// messages are written to be read on this screen.
// The REST half of the backend's surface (see server/src/routes.rs for the table); the SSE half is
// EventStream.kt. All blocking network calls -- invoke from a background dispatcher. Each throws
// ApiException carrying the server's own explanation where it sent one, since those messages are
// written to be read on this screen.
// Shared with EventStream.kt, which connects the same way but then reads
// without a deadline.
// Shared with EventStream.kt, which connects the same way but then reads without a deadline.
const val CONNECT_TIMEOUT_MS = 5000
private const val READ_TIMEOUT_MS = 5000
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
/**
* A request that did not produce what it asked for, carrying the server's own wording where it sent
* some.
*
* [status] is the HTTP status where there was a response at all, and null where the server was
* never reached. Callers that need it need it because the *same* failure is two different things to
* do: a 409 from a write is "somebody else changed this, here are three ways out". Nothing should
* branch on it to decide what to *say* -- the message is what says that.
*/
class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) :
Exception(message, cause)
/**
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
* failure translation every call needs. [readBody] gets the connected, already-status-checked
* connection to read from.
* connection.
*
* @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through
* here -- an event stream has no bounded read time (see EventStream.kt).
* here -- an event stream has no bounded read time.
*/
fun <T> requestFromServer(
settings: ServerSettings,
@@ -33,9 +41,9 @@ fun <T> requestFromServer(
method: String = "GET",
jsonBody: String? = null,
/**
* A request body written as it is produced -- the upload path. Content type, and a writer
* handed the connection's stream. Sent chunked, since what a writer will produce is not known
* up front and the point is that a file never sits whole in memory on this side.
* A request body written as it is produced -- the upload path. Sent chunked, since what a
* writer will produce is not known up front and the point is that a file never sits whole in
* memory.
*/
streamBody: Pair<String, (java.io.OutputStream) -> Unit>? = null,
readTimeoutMs: Int = READ_TIMEOUT_MS,
@@ -68,38 +76,36 @@ fun <T> requestFromServer(
detail.isNullOrEmpty() ->
"Server returned HTTP ${connection.responseCode} for $path"
else -> detail
}
},
status = connection.responseCode,
)
}
return readBody(connection)
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
// Surfacing the real exception (rather than one canned message for
// every failure mode) is what lets this be diagnosed on a device
// with no logcat access.
// Surfacing the real exception rather than one canned message for every failure mode is
// what lets this be diagnosed on a device with no logcat access.
throw ApiException(
"Couldn't reach the server at ${settings.baseUrl} " +
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
"this device able to reach that address (WireGuard up)?",
e,
cause = e,
)
} catch (e: Exception) {
throw ApiException(
"Reached ${settings.baseUrl}$path but couldn't read its response " +
"(${e::class.simpleName}: ${e.message})",
e,
cause = e,
)
} finally {
connection.disconnect()
}
}
/** The response body as one JSON object. */
private fun HttpURLConnection.jsonObject(): JSONObject =
JSONObject(inputStream.bufferedReader().readText())
/** The response body as a JSON array of objects, each mapped through [parse]. */
private fun <T> HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List<T> =
JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse)
@@ -108,21 +114,16 @@ private fun <T> JSONArray.mapObjects(parse: (JSONObject) -> T): List<T> =
private fun JSONArray.strings(): List<String> = (0 until length()).map { getString(it) }
/** Percent-encodes a value going into a query string. */
private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name())
// One row of GET /sessions. A session names the machine it runs on and
// which of that machine's providers it runs.
// One row of GET /sessions. A session names the machine it runs on and which of that machine's
// providers it runs.
data class SessionSummary(
val id: String,
/**
* Id of the machine this session runs on. Only ever used to *address* that machine -- to pick
* this session's row out of the per-machine usage snapshots for the header's five-hour bar.
*
* It was deliberately left out until 2026-08-29, on the grounds that nothing here addressed a
* setup and holding both the id and the name invited showing the wrong one, which had already
* happened once. Something addresses one now, so the reason lapsed rather than being overruled.
* The guard that replaces it is the rule below: never show this.
* this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a
* reader sees, and holding both invites showing the wrong one.
*/
val setup: String,
/** The machine's current label. This is the one to display; [setup] is never shown. */
@@ -135,9 +136,8 @@ data class SessionSummary(
* provider's kind rather than here from its name.
*
* What it licenses is narrow, and the delete dialog is worded to match: the driver keeps its
* own record of the conversation somewhere this app's delete does not reach. It is not a
* promise that the file is still there, and re-importing is not a restore -- this app's
* transcript holds things that record does not.
* own record somewhere this app's delete does not reach. It is not a promise that the file is
* still there, and re-importing is not a restore.
*/
val keepsOwnTranscript: Boolean,
/** How much the session asks before acting; null when it was never set. */
@@ -150,35 +150,32 @@ data class SessionSummary(
* Whether this session announces itself when it wants attention.
*
* Reported rather than assumed, for the same reason [permissionMode] is: a switch that draws
* itself from a default is one you can turn off while believing you are reading it. Defaults to
* on when a backend is too old to say, which matches what that backend actually does.
* itself from a default is one you can turn off while believing you are reading it.
*/
val notify: Boolean,
/**
* The directory the session works in, or null where it was never given one.
*
* Null is not "the home directory": it is the session never having been told, and what the
* process then starts in belongs to whatever launches it. Shown as unset rather than filled in
* with a guess, so a reader changing it is choosing rather than confirming.
* Null is not "the home directory": it is the session never having been told. Shown as unset
* rather than filled in with a guess, so a reader changing it is choosing rather than
* confirming.
*/
val cwd: String?,
/**
* How much context this session is holding, as the server last measured it -- see
* `SessionEvent.UsageDelta`.
* How much context this session is holding, as the server last measured it.
*
* Null where nothing has been measured: a session that has not run a turn, a provider that does
* not report usage, or a clear nobody has run a turn since. That is not zero, and the status
* row says so in words rather than drawing an empty context for a conversation that may be
* nearly full.
* full.
*/
val contextTokens: Long?,
/**
* The longest edge an image should have when it reaches this session, or null where the
* provider has no limit.
*
* Null and "a big number" are different answers, and only the first stays true: a provider that
* does not care about size should not be given a threshold this app invented. Decided by the
* server because that is where a provider's kind is known -- see `uploadPickedImage`.
* Null and "a big number" are different answers, and only the first stays true. Decided by the
* server because that is where a provider's kind is known.
*/
val maxImageEdge: Int?,
val status: String,
@@ -213,18 +210,16 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
*
* For screens whose controls are *set to* something rather than merely showing it. A screen opened
* from a list row carries the row the list last fetched, which is a snapshot: fine for a title,
* wrong for a switch, since a switch drawn from a stale row shows a position that may have been
* changed since -- here or on another device -- and nothing on screen says which.
* wrong for a switch, since a stale row shows a position that may have been changed since.
*/
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
// What the server offers, so the spawn screen has no hardcoded lists: a
// setup added to the server's config.ron appears here with no app rebuild.
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
// config.ron appears here with no app rebuild.
//
// One list rather than two. A provider only exists on a machine that has
// it installed, so offering machines and providers as independent choices
// would offer pairs that cannot work.
// One list rather than two. A provider only exists on a machine that has it installed, so offering
// machines and providers as independent choices would offer pairs that cannot work.
data class Provider(val name: String, val kind: String, val models: List<String>)
/**
@@ -263,7 +258,7 @@ fun fetchSetups(settings: ServerSettings): List<Setup> =
* A Claude Code session already on a machine, which can be continued here.
*
* Identified by [id] and never by a path. The server resolves which file that is, so this app has
* no way to ask it to read one -- the same rule that keeps a provider's command out of this client.
* no way to ask it to read one.
*/
data class Importable(
val id: String,
@@ -272,19 +267,15 @@ data class Importable(
val modified: Double,
val lines: Int,
/**
* Size of the session file in bytes.
*
* Worth a place on the row because it is the only thing there that predicts what continuing the
* session costs, and the line count does not: these transcripts embed screenshots as base64, so
* a single line can be a megabyte.
* Size of the session file in bytes. Worth a place on the row because it is the only thing
* there that predicts what continuing the session costs, and the line count does not: these
* transcripts embed screenshots as base64, so a single line can be a megabyte.
*/
val bytes: Long,
/**
* Tokens the model was holding at the last turn, or null if no turn has recorded any.
*
* The number that predicts what continuing this session costs. It disagrees with [bytes] in the
* direction that matters: most of a large transcript is usually history from before a
* compaction, which the model is no longer given.
* Tokens the model was holding at the last turn, or null if no turn has recorded any. It
* disagrees with [bytes] in the direction that matters: most of a large transcript is usually
* history from before a compaction, which the model is no longer given.
*/
val contextTokens: Long?,
/** Whether [title] is a name somebody chose rather than the last thing said in the session. */
@@ -293,17 +284,15 @@ data class Importable(
* Whether a Claude Code is running this session right now.
*
* "unknown" is a third answer and not a synonym for "no": the machine may keep no record of
* what is running, and a session that cannot be checked is not a session that is free. The
* server refuses an import of a "yes"; the row says so before you press it.
* what is running. The server refuses an import of a "yes"; the row says so before you press
* it.
*/
val inUse: String,
/**
* What this server is doing to the session right now -- "importing" or "deleting" -- or null
* when nothing is.
* What this server is doing to the session right now -- "importing" or "deleting" -- or null.
*
* The server's answer rather than the phone's, because the work outlives the screen that asked
* for it: leaving the import list and coming back has to show what is still running, and a
* phone that was asleep or out of range never saw the events that said so.
* for it: a phone that was asleep never saw the events that said so.
*/
val pending: String?,
/**
@@ -316,7 +305,7 @@ data class Importable(
/**
* One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing.
*
* [operation] is only set by a start, and [message] only by a failure -- the three states are every
* [operation] is only set by a start and [message] only by a failure -- the three states are every
* way an operation can be, and each carries exactly what that state knows.
*/
data class ImportableChange(
@@ -336,20 +325,18 @@ fun parseImportableChange(payload: String): ImportableChange? =
message = frame.optString("message").takeIf { it.isNotEmpty() },
)
} catch (_: org.json.JSONException) {
// A frame this build does not understand is not a reason to drop the stream: the listing
// is the truth and will say what happened whatever this missed.
// A frame this build does not understand is not a reason to drop the stream: the listing is
// the truth and will say what happened whatever this missed.
null
}
/**
* What a machine has that could be continued.
*
* The slowest call this app makes, and it was the only expensive one left on the 5 second default
* which is how it came to time out against a server that was answering perfectly well. Listing
* means reading every transcript Claude Code has ever written: about four seconds against a
* gigabyte of them before the tunnel adds anything, and that figure grows with every session
* anybody has. A timeout is for a server that has stopped answering, so it is set well clear of how
* long the work takes rather than just above it.
* The slowest call this app makes, and it was the only expensive one left on the 5 second default
* -- which is how it came to time out against a server answering perfectly well. Listing means
* reading every transcript Claude Code has ever written: about four seconds against a gigabyte of
* them before the tunnel adds anything. A timeout is for a server that has stopped answering.
*/
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) {
@@ -361,13 +348,11 @@ fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
modified = session.optDouble("modified", 0.0),
lines = session.optInt("lines", 0),
bytes = session.optLong("bytes", 0L),
// Absent means nothing has been measured -- which is not a context of zero, so
// it stays null and the row simply does not claim a figure.
// Absent means nothing has been measured, which is not a context of zero.
contextTokens =
if (session.isNull("contextTokens")) null
else session.optLong("contextTokens").takeIf { it > 0L },
// Absent means an older backend that cannot answer, which is exactly what
// "unknown" says -- so the default is the honest one rather than "no".
// Absent means an older backend that cannot answer, which is what "unknown" says.
inUse = session.optString("inUse", "unknown"),
named = session.optBoolean("named", false),
pending = session.optString("pending").takeIf { it.isNotEmpty() },
@@ -516,8 +501,7 @@ fun sendMessage(
*
* Throws rather than returning an outcome, because both ways of failing are things the reader has
* to be told: 409 means the session was already given it, and 404 means nothing is waiting under
* that id. The bubble disappearing is the success case and it arrives on the event stream, not from
* here -- every device drops it, not only the one that tapped.
* that id. The bubble disappearing arrives on the event stream, so every device drops it.
*/
fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) {
requestFromServer(
@@ -531,13 +515,11 @@ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: Strin
/**
* Moves a session to a different working directory.
*
* The server checks the directory is there on that machine and refuses if it is not -- a mistyped
* path accepted here would surface much later, as a session that would not start, with nothing
* pointing at the typo.
* The server checks the directory is there and refuses if it is not -- a mistyped path accepted
* here would surface much later, as a session that would not start.
*
* Its process is **stopped**, because a working directory is settled when the process is spawned.
* The next thing said to the session starts it again in the new one, which is this app's rule for a
* session with no process everywhere else.
* The next thing said to the session starts it again in the new one.
*/
fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) {
requestFromServer(
@@ -581,15 +563,179 @@ fun uploadAttachment(
write(out)
out.write(tail)
},
// Long: a trace is hundreds of megabytes, and the server copies it on to a remote
// machine before answering.
// Long: a trace is hundreds of megabytes, and the server copies it on to a remote machine
// before answering.
readTimeoutMs = 600000,
) { connection ->
connection.jsonObject().getString("id")
}
}
/** Fetches an image the transcript references (produced or uploaded). */
/**
* One entry of a directory on the machine a setup names.
*
* [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link]
* still says it is one. Neither is worked out here -- the machine answers both.
*/
data class DirEntry(
val name: String,
val kind: String,
val size: Long,
val modified: Long,
val link: Boolean,
) {
val isDirectory: Boolean
get() = kind == "directory"
}
/** A directory's entries, and the path the machine resolved the request to. */
data class Listing(val path: String, val entries: List<DirEntry>)
/**
* What reading a file produced.
*
* Four cases, because they are four different things to draw and none is an error the screen can
* shrug off: content, something that is not text, something too big to have sent, and (as
* [ApiException]) the machine's own refusal. A file with nothing in it is [FileContent.Text] with
* an empty string, which is what it is.
*/
sealed class FileContent {
abstract val path: String
abstract val size: Long
abstract val modified: Long
data class Text(
override val path: String,
override val size: Long,
override val modified: Long,
/** What a write is given back, to prove the file is still the one that was read. */
val sha256: String,
val content: String,
) : FileContent()
data class Binary(
override val path: String,
override val size: Long,
override val modified: Long,
) : FileContent()
data class TooBig(
override val path: String,
override val size: Long,
override val modified: Long,
) : FileContent()
}
/** What a file is after a write, so the editor's precondition is fresh without a second read. */
data class Written(val size: Long, val modified: Long, val sha256: String)
/** Everything in [path] on the machine [setup] names, and what [path] resolved to. */
fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/dir?path=${path.urlEncoded()}",
readTimeoutMs = 30000,
) { connection ->
val body = connection.jsonObject()
Listing(
path = body.getString("path"),
entries =
body.getJSONArray("entries").mapObjects { entry ->
DirEntry(
name = entry.getString("name"),
kind = entry.getString("kind"),
size = entry.optLong("size"),
modified = entry.optLong("modified"),
link = entry.optBoolean("link", false),
)
},
)
}
/** One file's content, or which of the reasons there is none to show. */
fun fetchFile(settings: ServerSettings, setup: String, path: String): FileContent =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}",
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any
// of it moves. Well clear of that rather than just above it.
readTimeoutMs = 60000,
) { connection ->
val body = connection.jsonObject()
val at = body.getString("path")
val size = body.optLong("size")
val modified = body.optLong("modified")
when (val kind = body.getString("kind")) {
"text" ->
FileContent.Text(
at,
size,
modified,
body.getString("sha256"),
body.getString("content"),
)
"binary" -> FileContent.Binary(at, size, modified)
"tooBig" -> FileContent.TooBig(at, size, modified)
// A backend that has learned a fifth answer. Reported rather than guessed at: picking
// the nearest of the four would draw something confident about a state never seen.
else ->
throw ApiException(
"The server described this file as \"$kind\", which this app does not know how to show."
)
}
}
/**
* Replaces a file's contents, but only while it still hashes to [ifSha256].
*
* The refusal is a 409 carrying the server's wording, which is what the conflict dialog shows -- an
* agent editing the same file while somebody reads it is the ordinary case here.
*/
fun writeFile(
settings: ServerSettings,
setup: String,
path: String,
content: String,
ifSha256: String,
): Written =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file",
method = "PUT",
jsonBody =
JSONObject()
.put("path", path)
.put("content", content)
.put("ifSha256", ifSha256)
.toString(),
readTimeoutMs = 60000,
) { connection ->
val body = connection.jsonObject()
Written(body.optLong("size"), body.optLong("modified"), body.getString("sha256"))
}
/** Creates an empty file. Refused, with the machine's own words, if the name is already taken. */
fun createFile(settings: ServerSettings, setup: String, path: String) {
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file",
method = "POST",
jsonBody = JSONObject().put("path", path).toString(),
readTimeoutMs = 30000,
) {}
}
/** Creates a directory, with the same refusal as [createFile]. */
fun createDir(settings: ServerSettings, setup: String, path: String) {
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/dir",
method = "POST",
jsonBody = JSONObject().put("path", path).toString(),
readTimeoutMs = 30000,
) {}
}
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
it.inputStream.readBytes()
@@ -598,10 +744,9 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String):
// One rate-limit window, rendered as a labeled bar on the usage screen.
data class UsageWindow(
/**
* The API's own word for which window this is -- "session" for the five-hour one.
*
* How to find a particular window. The label beside it is written for a person to read, so
* matching on it would select nothing the day its wording changes.
* The API's own word for which window this is -- "session" for the five-hour one. The label
* beside it is written for a person to read, so matching on it would select nothing the day its
* wording changes.
*/
val kind: String,
val label: String,
@@ -620,8 +765,8 @@ data class UsageSnapshot(
* What came back: "ok", "notLoggedIn", "unreachable" or "failed".
*
* Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a
* machine somebody chose not to put an account on -- a fact, not a fault -- while the other two
* are faults worth chasing. Collapsing them made a healthy setup read as broken.
* machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made
* a healthy setup read as broken.
*/
val state: String,
/** Why, for the two states that are faults. Absent otherwise. */
@@ -637,8 +782,8 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
provider = snapshot.getString("provider"),
setup = snapshot.optString("setup"),
setupName = snapshot.optString("setupName"),
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok"
// would draw an empty card as a healthy one.
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok" would
// draw an empty card as a healthy one.
state = snapshot.optString("state").ifEmpty { "failed" },
detail = snapshot.optString("detail").ifEmpty { null },
windows =
@@ -659,8 +804,7 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
* Answers one question with everything that was chosen.
*
* A list even when one thing was picked, because that is the shape of the answer rather than a
* special case of it. What a provider makes of several answers is its own business and is decided
* on the server; nothing here joins, splits or reformats them for one.
* special case of it. What a provider makes of several answers is decided on the server.
*/
fun answerQuestion(
settings: ServerSettings,
@@ -687,9 +831,8 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
/**
* Ends the process behind a session, leaving the session and its transcript.
*
* Not a delete and not an interrupt: the conversation stays exactly where it is and [startSession]
* picks it back up. The server reports what it could not do -- there was nothing running, or the
* machine would not say whether there was -- rather than answering the same way either way.
* Not a delete and not an interrupt: the conversation stays where it is and [startSession] picks it
* back up. The server reports what it could not do rather than answering the same way either way.
*/
fun stopSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {}
@@ -707,12 +850,10 @@ fun startSession(settings: ServerSettings, sessionId: String) {
* caller confirms first; see ImportScreen.
*
* The work runs on the server, so this returning is not the same as it being done -- what says that
* is each row's own state, through [fetchImportable] and the change stream. That is the point:
* leaving the screen used to cancel the delete it had started.
* is each row's own state. That is the point: leaving the screen used to cancel the delete.
*
* One request for the whole batch, which is what makes a handover all-or-nothing. Sending one per
* row meant a batch could half-arrive -- four deleted, two never asked for -- and the two that were
* missed looked exactly like two that had not been picked.
* One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant
* a batch could half-arrive, and the rows that were missed looked exactly like rows not picked.
*/
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
requestFromServer(
@@ -729,8 +870,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<S
* Separate from [spawnSession] because the two are asked different questions. That one means "start
* this and take me to it", so it waits and answers with the session. This is the import list's
* batch: several at once, nobody waiting on any particular one, and the result arrives as a row
* changing rather than as a reply -- which is what lets the screen be left. One request for all of
* them, for the reason [deleteImportable] gives.
* changing -- which is what lets the screen be left.
*/
fun startImport(
settings: ServerSettings,
@@ -760,7 +900,7 @@ fun startImport(
*
* One request instead of one stream frame per event. The SSE stream is the right shape for live
* events and the wrong one for a backlog: opening an imported session replayed hundreds of frames
* before anything was readable, which looked exactly like the app loading top-down, because it was.
* before anything was readable, which looked exactly like the app loading top-down.
*
* [before] pages backwards for history somebody scrolls to; absent means the newest page.
*/
@@ -769,21 +909,30 @@ fun fetchTranscript(
sessionId: String,
before: Long? = null,
limit: Int = 80,
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page
// of a delta-heavy conversation is a page of the screen rather than a fraction of one message.
// The scroll-back pager wants this; the anchor restore does not (it counts events to a known
// seq). Ignored by the server for the newest window, where the live cursor needs real seqs.
// See the server's `read_window`.
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
// a delta-heavy conversation is a page of the screen rather than a fraction of one message. The
// scroll-back pager wants this; the anchor restore does not. Ignored by the server for the
// newest window, where the live cursor needs real seqs.
coalesce: Boolean = false,
): List<SeqEvent> {
// Return nothing at or below this seq, stopping the page here instead of at [limit]. The phone
// passes the end of the run it already holds cached, so a page never overlaps that copy -- an
// overlap it cannot store, since a coalesced event cannot be cut inside its own delta run.
after: Long? = null,
): List<Pair<String, SeqEvent>> {
val query = buildString {
append("?limit=").append(limit)
if (before != null) append("&before=").append(before)
if (coalesce) append("&coalesce=true")
if (after != null) append("&after=").append(after)
}
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
val body = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until body.length()).map { parseSeqEvent(body.getJSONObject(it).toString()) }
// The text as well as the event: the transcript cache stores the one and the fold needs the
// other, and they have to be the same line.
(0 until body.length()).map {
val line = body.getJSONObject(it).toString()
line to parseSeqEvent(line)
}
}
}
@@ -793,7 +942,7 @@ fun fetchTranscript(
* The name is the backend's own -- it is what the list shows and it exists before any process does
* -- so this settles it rather than asking. Where the thing running the session has a name of its
* own, the backend passes it on, which is what makes a session the same session in Claude Code's
* picker and to any other agent that lists it.
* picker.
*/
fun renameSession(settings: ServerSettings, sessionId: String, title: String) {
requestFromServer(
@@ -885,10 +1034,9 @@ fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Bo
requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {}
}
// Models: what this backend has downloaded, what it is downloading, and
// what HuggingFace offers. Browsing is proxied by the server rather than
// done here, because this app trusts exactly one certificate and has no
// general internet trust to spend on huggingface.co.
// Models: what this backend has downloaded, what it is downloading, and what HuggingFace offers.
// Browsing is proxied by the server rather than done here, because this app trusts exactly one
// certificate and has no general internet trust to spend on huggingface.co.
data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long)
@@ -29,16 +29,22 @@ import kotlinx.coroutines.withContext
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* and the back button the only other way between them.
*
* Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views
* of the same backend, none of them a step down from another -- and what is left in this `when` is
* only what genuinely is a step down: one session, spawning one, and settings. A session's own
* settings are not among them: they are a dialog over the session, which is where the thing they
* change is.
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
* them a step down from another -- and what is left here is only what genuinely is a step down: one
* session, spawning one, and settings.
*/
private sealed class Screen {
data object Main : Screen()
data class Session(val summary: SessionSummary) : Screen()
/**
* One session, with the file explorer over it when [files] is set.
*
* The explorer is a layer on this screen rather than a screen of its own, so the session under
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
* created on every return, refetching the transcript over the tunnel.
*/
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
data object Spawn : Screen()
@@ -50,7 +56,7 @@ private sealed class Screen {
*
* The notification names an id and nothing else, so opening it means fetching the session first.
* [serial] tells two taps on the same session's notification apart, since they are two requests and
* would otherwise compare equal -- see MainActivity, which counts them.
* would otherwise compare equal.
*/
data class SessionOpenRequest(val sessionId: String, val serial: Int)
@@ -58,8 +64,8 @@ data class SessionOpenRequest(val sessionId: String, val serial: Int)
private data class FailedOpen(val request: SessionOpenRequest, val message: String)
/**
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity),
* re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), re-
* reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
*
* [openRequest] is the session a notification tap asked for, likewise from MainActivity.
*
@@ -79,8 +85,8 @@ fun AppRoot(
// A notification tap this could not follow, and why. Null both before one is asked for and
// after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
// Bumped whenever another screen changes something the list shows, so
// returning to it refetches instead of showing a stale list.
// Bumped whenever another screen changes something the list shows, so returning to it
// refetches.
var reloadToken by remember { mutableIntStateOf(0) }
// Cleared by the session screen that attached it, not when a newer request arrives: a share
// must be attached exactly once, and only the screen that did it knows that it has.
@@ -94,9 +100,8 @@ fun AppRoot(
}
}
// A standing condition rather than a per-request failure, so it is
// stated once here instead of appended to every error that might be
// caused by it. Without this the app is simply unreachable and every
// A standing condition rather than a per-request failure, so it is stated once here instead of
// appended to every error it might cause. Without this the app is simply unreachable and every
// screen blames the server or the tunnel for it.
if (!localNetworkAllowed(context)) {
Text(
@@ -111,8 +116,8 @@ fun AppRoot(
val current = settings
if (current == null) {
// Not enrolled yet: settings is the only usable screen. The QR
// path lands in MainActivity and recomposes from the top.
// Not enrolled yet: settings is the only usable screen. The QR path lands in MainActivity
// and recomposes from the top.
Box(Modifier.imePadding()) {
SettingsScreen(
existing = null,
@@ -126,10 +131,9 @@ fun AppRoot(
return
}
// The one way back, whichever screen is showing and whether it was
// reached by the system back gesture or a screen's own Back button.
// Every leaf screen can have changed something the list shows, so it
// always refetches.
// The one way back, whichever screen is showing and whether it was reached by the system back
// gesture or a screen's own Back button. Every leaf screen can have changed something the list
// shows, so it always refetches.
val goToMain = {
reloadToken++
screen = Screen.Main
@@ -139,8 +143,8 @@ fun AppRoot(
}
// Turning a notification into the screen it points at. The id has to be resolved to a session
// first, because that is what SessionScreen is given -- and unlike a list row, which is a
// snapshot the list already fetched, there is nothing here to seed it from.
// first, because that is what SessionScreen is given -- and unlike a list row, there is nothing
// here to seed it from.
//
// A failure is reported rather than swallowed: somebody deliberately tapped a notification, so
// an app that opens to the session list with no explanation looks like the tap missed.
@@ -170,10 +174,9 @@ fun AppRoot(
)
}
// Every screen but the session takes the keyboard as bottom padding here. The session
// screen deliberately does not: resizing a whole screen on every frame of the keyboard
// animation is the cost that made it lag, so it moves only its composer and transcript --
// see the layout note in SessionScreen.
// Every screen but the session takes the keyboard as bottom padding here. The session screen
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is
// the cost that made it lag, so it moves only its composer and transcript.
when (val here = screen) {
is Screen.Main ->
Box(Modifier.imePadding()) {
@@ -192,27 +195,36 @@ fun AppRoot(
}
is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this
// one showing other rows. SessionScreen remembers a transcript, an open event stream, a
// draft and a scroll position, and without the key Compose keeps all of it across the
// change and merges two conversations -- which crashes the list on the first duplicate
// row key. Only reachable since a notification can move straight from one session to
// another; every other way here passes through [Screen.Main], which disposes it anyway.
// one showing other rows. SessionScreen remembers a transcript, an open stream, a draft
// and a scroll position, and without the key Compose keeps all of it across the change
// and merges two conversations -- which crashes the list on the first duplicate row
// key. Only reachable since a notification can move straight from one session to
// another.
key(here.summary.id) {
// The gesture goes on a box around the screen rather than inside it, so it is the
// outermost thing in the tree and everything within has already had its chance at
// the drag. See [swipeBack]. No imePadding here, for the reason above.
Box(Modifier.swipeBack(goToMain)) {
// A Box so the explorer can be drawn *over* the session rather than instead of it.
// No imePadding here, for the reason above -- the explorer adds its own.
Box {
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToMain,
onFiles = { screen = here.copy(files = it) },
share = share,
onShareTaken = { share = null },
)
// Its own back handler is registered after this screen's, so it is the one the
// platform asks first, and it steps back inside itself before closing.
here.files?.let { target ->
FilesScreen(
settings = current,
target = target,
onClose = { screen = here.copy(files = null) },
)
}
}
}
is Screen.Spawn ->
Box(Modifier.imePadding().swipeBack(goToMain)) {
Box(Modifier.imePadding()) {
SpawnScreen(
settings = current,
onSpawned = { spawned ->
@@ -223,7 +235,7 @@ fun AppRoot(
)
}
is Screen.Settings ->
Box(Modifier.imePadding().swipeBack(goToMain)) {
Box(Modifier.imePadding()) {
SettingsScreen(
existing = current,
onSaved = { saved ->
@@ -237,8 +249,7 @@ fun AppRoot(
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
// the activity puts around this, and that Box paints in the order it was given. A session
// wanting attention is not a fact about the page somebody happens to be on, so it is not the
// page's job to leave room for it. Tapping one is the same act as tapping a notification, so
// it goes through the same `open`, failure dialog included.
// wanting attention is not a fact about the page somebody happens to be on. Tapping one is the
// same act as tapping a notification, so it goes through the same `open`.
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
}
@@ -41,13 +41,12 @@ data class QuestionAnswer(val questionId: String, val answers: List<String>)
* What the reader has settled on for one question, before any of it is sent.
*
* Held here rather than inferred from the transcript, which is what made picking an option feel
* broken: the mark used to appear only when the answer had crossed the tunnel, been recorded and
* come back as an event, so on a phone the card sat unchanged for most of a second after a tap and
* the natural response was to tap again.
* broken: the mark used to appear only when the answer had crossed the tunnel and come back as an
* event, so the card sat unchanged for most of a second after a tap.
*
* Picked options and typed words are one field each because they are alternatives rather than
* parts: answering in the reader's own words is the case no option covers, so typing puts the picks
* away and picking puts the words away, and there is never a draft that means two things.
* parts: typing puts the picks away and picking puts the words away, so there is never a draft that
* means two things.
*/
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
val settled: Boolean
@@ -65,29 +64,26 @@ data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
/**
* Every question one tool call is waiting on, one at a time.
*
* All of it comes from the question events themselves -- what each option means, what picking it
* would produce, whether several may be picked at once. None of it is read out of the call's own
* All of it comes from the question events themselves. None of it is read out of the call's own
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
* app, where no other provider can reach it and where it drifts the first time the schema moves.
*
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
* three questions with four options and a description each is several screens tall, so the reader
* scrolls past the question they are answering to reach the button that sends it, and never sees
* the whole of any one of them. Paged, each question is a screen and the count says how many are
* left -- which is also what makes "not all of them are answered" something the reader can act on
* rather than something to go hunting for.
* scrolls past the question they are answering to reach the button that sends it. Paged, each
* question is a screen and the count says how many are left.
*
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
* asked them together and is waiting on all of them, and sending each as it was tapped meant the
* reader could not change their mind about the first after reading the third.
* asked them together, and sending each as it was tapped meant the reader could not change their
* mind about the first after reading the third.
*/
@Composable
fun AskUserQuestionBody(
asks: List<TranscriptItem.QuestionCard>,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) {
// Seeded from what was already answered, so a card the reader comes back to shows their
// answers rather than an empty draft over them.
// Seeded from what was already answered, so a card the reader comes back to shows their answers
// rather than an empty draft over them.
var drafts by
remember(asks.map { it.id }) {
mutableStateOf(
@@ -125,7 +121,7 @@ fun AskUserQuestionBody(
modifier = Modifier.weight(1f),
)
// Disabled at the ends rather than absent, so the pair keeps its place and the
// reader can see that there is nothing further that way.
// reader can see there is nothing further that way.
MarkButton("Previous question", { at-- }, enabled = at > 0) {
Chevron(Pointing.Left, colour = LocalContentColor.current)
}
@@ -143,7 +139,7 @@ fun AskUserQuestionBody(
if (outstanding.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
// Greyed until every question has an answer, because the tool is waiting on all of
// them: a submit that sent two of three would leave the third one asked and the card
// them: a submit that sent two of three would leave the third asked and the card
// looking dealt with.
val ready = outstanding.all { drafts[it.id]?.settled == true }
Button(
@@ -155,8 +151,8 @@ fun AskUserQuestionBody(
}
) {
// Back to a button whatever happened. A refusal is reported by the screen
// around this, and the draft is still here to send again -- a spinner
// that never stops would be the only sign of a failure this card cannot
// around this, and the draft is still here to send again -- a spinner that
// never stops would be the only sign of a failure this card cannot
// describe.
sending = false
}
@@ -165,8 +161,8 @@ fun AskUserQuestionBody(
modifier = Modifier.fillMaxWidth(),
) {
if (sending) {
// In the button rather than beside it, so the row does not change height at
// the moment it is pressed.
// In the button rather than beside it, so the row does not change height at the
// moment it is pressed.
CircularProgressIndicator(
Modifier.height(18.dp).width(18.dp),
strokeWidth = 2.dp,
@@ -186,8 +182,7 @@ fun AskUserQuestionBody(
* One question: what is being asked, what can be answered, and what was.
*
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
* when nothing did. A question is the same thing either way, and two renderings of it would be two
* places for an answer to go missing.
* when nothing did. Two renderings of it would be two places for an answer to go missing.
*
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
* sends anything. An answered question ignores both and draws what was answered.
@@ -214,10 +209,10 @@ fun AskedQuestion(
// replacing them with a line repeating it. The options are what the question *was*, and
// dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says
// very little without the three it was chosen over. Marked in the same purple that says
// "picked" while the question is still open, so it is one appearance learned once.
// "picked" while the question is open, so it is one appearance learned once.
val answered = ask.answers.isNotEmpty()
// What is marked: what was answered once there is an answer, and what the finger has
// chosen until then.
// What is marked: what was answered once there is an answer, and what the finger has chosen
// until then.
val marked = if (answered) ask.answers.toSet() else draft.picked
// Null once the question is answered: the options stay and stop being pressable.
val onPick: ((String) -> Unit)? =
@@ -233,9 +228,9 @@ fun AskedQuestion(
}
}
}
// What was answered in the reader's own words, which no option can mark -- see
// [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered
// by picking says it by the mark alone.
// What was answered in the reader's own words, which no option can mark. Only ever the
// answers that match nothing offered, so a question answered by picking says it by the
// mark.
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
if (inWords.isNotEmpty()) {
Text(
@@ -252,10 +247,8 @@ fun AskedQuestion(
}
/**
* [label] added to, or taken out of, what [draft] has picked.
*
* A single-answer question replaces rather than accumulates, and either way picking puts any typed
* words away -- see [Draft].
* [label] added to, or taken out of, what [draft] has picked. A single-answer question replaces
* rather than accumulates, and either way picking puts any typed words away -- see [Draft].
*/
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
when {
@@ -269,8 +262,7 @@ private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
*
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
* indistinguishable from the card behind it -- three paragraphs of text where three things to press
* should have been, which is the failure a tint step routinely produces on a dark theme. A border
* is one cue and it is unambiguous.
* should have been. A border is one cue and it is unambiguous.
*/
@Composable
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
@@ -324,8 +316,8 @@ private fun Preview(preview: String) {
preview,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines
// of the thing being previewed.
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines of
// the thing being previewed.
softWrap = false,
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
)
@@ -336,8 +328,7 @@ private fun Preview(preview: String) {
* The choice the asker always leaves open, and the app has to as well.
*
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
* rather than pick. Leaving it out narrows a question that was never that narrow, and the reader
* cannot tell that it was ever open.
* rather than pick. Leaving it out narrows a question that was never that narrow.
*/
@Composable
private fun OtherAnswer(text: String, onText: (String) -> Unit) {
@@ -358,8 +349,7 @@ private fun OtherAnswer(text: String, onText: (String) -> Unit) {
*
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
* with four options showed the first one or two and dropped the rest off the side of the screen.
* That does not read as a bug: it reads as those having been the only choices, which is the worst
* way for a list of choices to be wrong.
* That reads as those having been the only choices.
*/
@Composable
fun AnswerOptions(
@@ -379,8 +369,8 @@ fun AnswerOptions(
OutlinedButton(
onClick = { onPick?.invoke(option.label) },
// Disabled rather than removed, so an answered question still shows what it
// offered. Material dims a disabled button's own border and label, which would
// take the mark with it -- both are stated here instead.
// offered. Material dims a disabled button's own border and label, which would take
// the mark with it -- both are stated here instead.
enabled = onPick != null,
border =
BorderStroke(
@@ -28,8 +28,8 @@ fun attachmentName(ref: String): String = ref.substringAfter('-', ref)
/**
* One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A
* file is not fetched -- there is nothing on this phone to open a trace or a log with -- so the
* name is the whole of it.
* file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all
* of it.
*/
@Composable
fun Attachment(
@@ -50,8 +50,7 @@ fun Attachment(
/**
* A file's name, one line, in the face names are read in. Overlong names lose their middle: a name
* is identified by both ends -- what it is at the front, what kind at the back -- and either
* ellipsis alone takes away one of them.
* is identified by both ends -- what it is at the front, what kind at the back.
*/
@Composable
fun FileName(name: String, modifier: Modifier = Modifier) {
@@ -20,10 +20,8 @@ import kotlin.math.max
* to be either thrown away or rejected -- which is what "sending an image is broken" was.
*
* Shrunk here rather than on the backend, so the bytes that never mattered are never sent: the
* expensive part of this on a phone is the upload, not the decode. What the limit *is* comes from
* the server, per session -- see `DriverKind::max_image_edge` -- because that is where a provider's
* requirements are known, and a phone that carried its own copy of them would be a second place to
* update when one changes.
* expensive part on a phone is the upload, not the decode. What the limit *is* comes from the
* server, per session, because that is where a provider's requirements are known.
*/
suspend fun uploadPickedImage(
context: Context,
@@ -53,18 +51,17 @@ suspend fun uploadPicked(
if (mime != null && mime.startsWith("image/")) {
return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
}
// Opened before the request starts, so a provider that refuses says so here and not from
// inside the connection; then streamed, since a trace or a log is bigger than this process
// should hold at once.
// Opened before the request starts, so a provider that refuses says so here and not from inside
// the connection; then streamed, since a trace is bigger than this process should hold at once.
val source = openSource(resolver, uri)
val name = displayName(resolver, uri)
return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out ->
try {
source.use { it.copyTo(out, COPY_BUFFER) }
} catch (e: java.io.IOException) {
// Either side of the copy can fail; the message names the file, which is the
// part the reader can do something about.
throw ApiException("couldn't send $name: ${e.message}", e)
// Either side of the copy can fail; the message names the file, which is the part the
// reader can do something about.
throw ApiException("couldn't send $name: ${e.message}", cause = e)
}
}
}
@@ -76,7 +73,7 @@ private const val COPY_BUFFER = 64 * 1024
*
* A share arrives with whatever access the other app granted, and a provider that refuses says so
* with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both
* are things the reader can act on, so neither is left to end the process.
* are things the reader can act on.
*/
private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream =
try {
@@ -110,9 +107,9 @@ private fun displayName(resolver: ContentResolver, uri: Uri): String {
/**
* The bytes to upload and what they are, scaled down only if they need to be.
*
* An image already inside the limit is uploaded exactly as it came, rather than decoded and
* re-encoded to the same size: a round trip through JPEG loses a little every time, and there is
* nothing to gain from it. This is also the path a provider with no limit always takes.
* An image already inside the limit is uploaded exactly as it came, rather than decoded and re-
* encoded to the same size: a round trip through JPEG loses a little every time. This is also the
* path a provider with no limit always takes.
*/
private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteArray, String> {
val resolver = context.contentResolver
@@ -128,9 +125,9 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteA
// decision it has no business making.
if (longest <= 0 || longest <= maxEdge) return original to mime
// Powers of two first, which is all the decoder can do, and then the exact scale. Decoding
// the full twelve megapixels only to shrink it is how this runs out of memory on the images
// it most needs to handle.
// Powers of two first, which is all the decoder can do, and then the exact scale. Decoding the
// full twelve megapixels only to shrink it is how this runs out of memory on the images it most
// needs to handle.
val decode =
BitmapFactory.Options().apply {
inSampleSize = Integer.highestOneBit(max(1, longest / maxEdge))
@@ -141,9 +138,8 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteA
val matrix = Matrix()
if (scale < 1f) matrix.postScale(scale, scale)
// The camera writes which way up the picture is into EXIF rather than rotating the pixels, and
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side, with
// nothing anywhere saying so. Applied to the same matrix as the scale, so it costs no second
// copy of the bitmap.
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side.
// Applied to the same matrix as the scale, so it costs no second copy of the bitmap.
matrix.postRotate(exifRotation(original))
val scaled = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
val out = ByteArrayOutputStream()
@@ -166,8 +162,8 @@ private fun exifRotation(bytes: ByteArray): Float =
else -> 0f
}
} catch (_: java.io.IOException) {
// No EXIF, or none this can read. Upright is the assumption every
// image without the tag is displayed under anyway.
// No EXIF, or none this can read. Upright is the assumption every image without the tag is
// displayed under anyway.
0f
}
@@ -8,18 +8,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
// The composer's row of settings and pickers, and the menus they open. One file because the
// outline and the corner are one appearance: a control shaped like this opens a surface shaped
// like this, and a reader learns the pair once.
// The composer's row of settings and pickers, and the menus they open. One file because the outline
// and the corner are one appearance: a control shaped like this opens a surface shaped like this.
/**
* A bordered pill: a control that can be seen without being pressed.
*
* The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at
* all until they are touched. Three bare words sitting under the message field read as a caption
* about the field rather than as three things to press, and the only way to find out otherwise was
* to press one. The outline says "control" without the weight of a filled button, which is reserved
* here for the two that act on the session (send, and start/stop).
* all until they are touched. Three bare words under the message field read as a caption about the
* field rather than as three things to press. The outline says "control" without the weight of a
* filled button, which is reserved for the two that act on the session.
*/
@Composable
fun BubbleButton(
@@ -32,8 +30,8 @@ fun BubbleButton(
onClick = onClick,
enabled = enabled,
shape = BubbleShape,
// A text button's padding rather than a filled button's 24dp: these sit three across
// under the message field, and the wider padding is what decides whether the row fits.
// A text button's padding rather than a filled button's 24dp: these sit three across under
// the message field, and the wider padding is what decides whether the row fits.
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier,
) {
@@ -48,7 +46,6 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
* The corner on a menu one of these opens.
*
* A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding
* ends that tall would bow its sides. This is the roundest corner that still leaves a straight edge
* beside a one-line option, which is the shortest menu here.
* ends that tall would bow its sides.
*/
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
@@ -26,20 +26,16 @@ import androidx.compose.ui.unit.dp
* of the operation over it.
*
* One composable rather than a pattern each list repeats, because "this row is busy" has to look
* the same in the import list and the session list or the appearance becomes a per-screen dialect
* rather than something the reader learns once.
* the same in the import list and the session list or the appearance becomes a per-screen dialect.
*
* [label] names the operation and `null` means none is running. One parameter rather than a boolean
* beside a string, which can disagree: there is no such thing as busy with nothing happening. It is
* a *word* because a spinner alone cannot say which operation this is deleting and importing are
* different in kind, and losing a session to the wrong one is not recoverable by waiting.
* beside a string, which can disagree. It is a *word* because a spinner alone cannot say which
* operation this is -- deleting and importing are different in kind.
*
* It does **not** make the row inert; the caller disables its own click handling while it passes a
* label. That was the other way round at first an overlay consuming pointer events, so no caller
* had to remember — and it swallowed the drag along with the tap, which meant a list could not be
* scrolled while anything in it was busy. Consuming taps but not drags means re-deciding what a
* gesture is above the components that already decide it; disabling the click is the platform's own
* answer and leaves the scroll where it belongs.
* label. That was the other way round at first -- an overlay consuming pointer events -- and it
* swallowed the drag along with the tap, so a list could not be scrolled while anything in it was
* busy.
*/
@Composable
fun BusyItem(label: String?, content: @Composable () -> Unit) {
@@ -71,14 +67,12 @@ fun BusyItem(label: String?, content: @Composable () -> Unit) {
/**
* How an item looks while it is being acted on: darker, and nearly grey.
*
* Both, rather than either alone. Dimming by itself is what this app already used for a row on its
* way out, and it is the same cue as a disabled control, so a busy row read as one more thing that
* could not be tapped. Draining the colour is what says the row is *suspended* — the status word,
* the accent on a warning and everything else that means something by its colour stop meaning it
* for as long as the operation runs, which is exactly true: none of them is being kept up to date.
* Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a
* busy row read as one more thing that could not be tapped. Draining the colour is what says the
* row is *suspended* -- the status word and everything else that means something by its colour stop
* meaning it for as long as the operation runs, which is exactly true.
*
* Not all the way to grey. A row with no colour left is hard to find again in a list, and the
* reader is watching this one.
* Not all the way to grey: a row with no colour left is hard to find again in a list.
*/
private fun Modifier.busy(busy: Boolean): Modifier =
if (!busy) this
@@ -27,13 +27,10 @@ enum class Pointing {
*
* One composable for all four directions rather than one per axis that differ by which coordinate
* gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one
* direction. The shape is written once in its own coordinates, where x runs across the opening and
* y runs from the open side to the tip, and [Pointing] is only a table of how those two map onto
* the box.
* direction. The shape is written once in its own coordinates, and [Pointing] is only a table of
* how those map onto the box.
*
* It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole
* of what assistive technology has to go on, and it is also the answer to "what was that arrow for"
* six months from now.
* It draws no label of its own, so every caller owes it a `contentDescription`.
*/
@Composable
fun Chevron(
@@ -30,14 +30,12 @@ import org.intellij.markdown.ast.getTextInNode
* sits on, scrolling sideways rather than wrapping.
*
* The renderer's own fence drew the same block in plain text. The scanner that colours a tool
* call's command colours a reply's code the same way, through [highlighted] and one palette, so a
* `kotlin` fence and the Kotlin a tool wrote are the same colours. A fence in a language [scan] has
* no rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and
* plain is what the reader would have seen before.
* call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool
* wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than
* wrongly coloured.
*
* Finding the code is still the library's: which children of the node are the fence markers, the
* language word and the code between them is its knowledge of the parser, and [MarkdownCodeFence]
* hands out the code and the language and leaves the drawing to the block it is given.
* language word and the code between them is its knowledge of the parser.
*/
@Composable
fun CodeFence(
@@ -67,14 +65,12 @@ fun CodeBlock(
/**
* The code inside a fence or indented block, and the highlighter's language for its info word.
*
* Which children of the node are the fence markers, the language word and the code between them is
* the library's knowledge of the parser, copied from its `MarkdownCodeFence` rather than called:
* that one is a composable, and the whole point of this function is that [warm] can run it on a
* background thread and highlight the same string the drawing will ask for. Two extractions would
* be two keys, and the warmed answer would be silently missed at every fence.
* Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and
* the whole point here is that [warm] can run this on a background thread and highlight the same
* string the drawing will ask for. Two extractions would be two keys, and the warmed answer would
* be silently missed at every fence.
*
* Null for a fence too short to hold anything -- an unterminated one still arriving, which the
* library skips as invalid.
* Null for a fence too short to hold anything -- an unterminated one still arriving.
*/
fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
val word =
@@ -97,7 +93,6 @@ fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
*
* The renderer's own block, less what nothing here needs: the same background, corner, padding and
* sideways scroll, without the shadow, the border and the empty pointer handler it also carried.
* The vertical margin is the renderer's too, kept so a reply's fences sit where they always have.
*/
@Composable
private fun CodeBlockText(
@@ -117,8 +112,7 @@ private fun CodeBlockText(
.semantics { isTraversalGroup = true }
) {
BasicText(
// No language while the block is still being written, which is what draws it plain;
// see [MarkdownRoot]'s `streaming`.
// No language while the block is still being written, which is what draws it plain.
replies.highlighted(code, language.takeUnless { streaming }),
style = style,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
@@ -137,6 +131,23 @@ private fun CodeBlockText(
fun fenceLanguage(name: String?): Language? =
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
/**
* The highlighter's language for a *file*, from its name.
*
* The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people
* write after the backticks. One table rather than two, so a language added for fences is a
* language added for files and neither can be the one somebody forgot.
*
* The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin. A
* leading dot is not one: `.bashrc` has no extension, it has a name that starts with a dot. A name
* with no dot at all -- `Makefile` -- is likewise null, and null is drawn plain.
*/
fun fileLanguage(name: String): Language? {
val dot = name.lastIndexOf('.')
if (dot < 1) return null
return fenceLanguage(name.substring(dot + 1))
}
private val FENCE_LANGUAGES: Map<String, Language> =
mapOf(
"kotlin" to Language.KOTLIN,
@@ -182,13 +193,14 @@ private val FENCE_LANGUAGES: Map<String, Language> =
"toml" to Language.TOML,
"fish" to Language.FISH,
"json" to Language.JSON,
"markdown" to Language.MARKDOWN,
"md" to Language.MARKDOWN,
)
/**
* Every fence in [parse], as the code and language [highlight] will be asked for.
*
* Walks the whole tree rather than the top level: a fence inside a list item or a quote is drawn
* the same way and costs the same to lex.
* Every fence in [parse], as the code and language [highlight] will be asked for. Walks the whole
* tree rather than the top level: a fence inside a list item or a quote is drawn the same way and
* costs the same to lex.
*/
fun fences(parse: State): List<Pair<String, Language?>> {
val success = parse as? State.Success ?: return emptyList()
@@ -25,8 +25,7 @@ import androidx.compose.ui.unit.dp
* These are the two this app understands, and understanding them is what lets it show them: a
* suggestion while one is being typed, a name in the settings screen that sends one, and a bubble
* that stays up while the session is too busy to run it. Anything else beginning with "/" is passed
* through to whatever runs the session, because a dialect's own vocabulary is its own and grows
* without this list -- it just arrives unannounced and unexplained.
* through, because a dialect's own vocabulary grows without this list.
*/
data class SessionCommand(
/** With the slash, as it is typed and as it is sent. */
@@ -90,8 +89,8 @@ fun CommandSuggestions(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
// The command in the colour commands are, so the suggestion and the
// bubble it becomes are visibly the same thing.
// The command in the colour commands are, so the suggestion and the bubble
// it becomes are visibly the same thing.
if (command.argument == null) command.name
else "${command.name} <${command.argument}>",
style = MaterialTheme.typography.titleSmall,
@@ -117,8 +116,7 @@ fun CommandSuggestions(
* anything appearing here.
*
* [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes
* and reads as having been missed.
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes.
*/
@Composable
fun CommandBubble(text: String, waiting: Boolean = false) {
@@ -128,8 +126,8 @@ fun CommandBubble(text: String, waiting: Boolean = false) {
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
) {
Column(Modifier.padding(12.dp)) {
// Stated beside the fill rather than inherited: a semantic colour has to carry
// its own contrast, because the surface under it will not change to rescue it.
// Stated beside the fill rather than inherited: a semantic colour has to carry its
// own contrast, because the surface under it will not change to rescue it.
Text(text, color = MaterialTheme.colorScheme.inverseOnSurface)
if (waiting) {
Spacer(Modifier.height(6.dp))
@@ -7,12 +7,10 @@ import androidx.compose.ui.Modifier
* The mark a compaction leaves in the transcript.
*
* A divider rather than something anybody said: everything above it is out of the session's context
* now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it
* is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which
* a clear also uses, so the two marks cannot drift apart.
* now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider],
* which a clear also uses, so the two marks cannot drift apart.
*
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it,
* which is the same thing the status line says while the compaction runs.
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it.
*/
@Composable
fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) {
@@ -23,9 +21,8 @@ fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifi
* What to say about a compaction: the two sizes, and nothing else.
*
* The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer
* to why the wait was worth it -- and they are all this says, because a divider is read in passing.
* When they were not reported this says only that a compaction happened, rather than filling in a
* plausible number or explaining at length what was missing.
* to why the wait was worth it. When they were not reported this says only that a compaction
* happened, rather than filling in a plausible number.
*/
fun compactionSummary(item: TranscriptItem.CompactedNote): String {
val pre = item.preTokens
@@ -41,8 +38,8 @@ fun compactionSummary(item: TranscriptItem.CompactedNote): String {
* A token count as a reader reads one.
*
* Shared with the status row rather than formatted at each: the divider and the row report the same
* quantity about the same moment, and one of them grouping its thousands while the other did not
* read as two different measurements.
* quantity about the same moment, and one grouping its thousands while the other did not read as
* two different measurements.
*/
fun tokens(count: Long): String = "%,d".format(count)
@@ -50,15 +47,12 @@ fun tokens(count: Long): String = "%,d".format(count)
* What the working indicator says while a compaction is running.
*
* Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a
* compaction has begun and then says nothing until it has finished, so any bar, percentage or
* estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been
* going forty seconds is what a reader actually wants -- it is the difference between waiting and
* going to look at why.
* compaction has begun and then says nothing until it has finished, so any bar or estimate here
* would be this screen's guess wearing a measurement's clothes.
*
* [seconds] is null when this device did not see the compaction start, which is what opening a
* session that is already compacting looks like. That case says only "compacting": no number is the
* honest answer, and a number counted from the moment the screen opened would be wrong in the
* direction that matters, since a compaction somebody is asking about is a long one.
* session that is already compacting looks like. That case says only "compacting": a number counted
* from the moment the screen opened would be wrong in the direction that matters.
*/
fun compactingLabel(seconds: Long?): String =
when {
@@ -12,10 +12,9 @@ import java.util.Locale
* The last crash, kept so the debug button can hand it over.
*
* The alternative is asking somebody to reproduce a crash with the phone plugged into a computer
* and `logcat` running, which is the one thing nobody has set up at the moment it happens -- and a
* crash report that arrives a day later, without the stack, is a guess. This costs one file write
* on a process that is already dying, and it turns "it crashes when I open that chat" into the
* frame it crashed in.
* and `logcat` running, which is the one thing nobody has set up at the moment it happens. This
* costs one file write on a process that is already dying, and it turns "it crashes when I open
* that chat" into the frame it crashed in.
*
* Kept until it is read rather than cleared on the next launch: the app restarts before anybody can
* ask about it, so a log that lives for one session is a log that is never read.
@@ -26,8 +25,7 @@ private const val CRASH_FILE = "last-crash.txt"
* How much of a stack is kept.
*
* This is pasted into a conversation, so it has a budget like any other output written for a
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing, so
* what gets cut is the part nobody reads.
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing.
*/
private const val CRASH_LIMIT = 4000
@@ -35,8 +33,7 @@ private const val CRASH_LIMIT = 4000
* Records uncaught exceptions, then lets the platform do what it was going to do.
*
* Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and
* ends the process, and an app that swallows that instead sits there in an unknown state. This only
* adds a witness.
* ends the process, and an app that swallows that instead sits there in an unknown state.
*/
fun installCrashLog(context: Context) {
val app = context.applicationContext
@@ -12,10 +12,9 @@ import java.util.concurrent.atomic.AtomicLong
*
* Here because the emulator cannot answer the question this is for. Its own scroll sits at the same
* frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost
* is under the floor of what it can measure, and a frame number taken in it says nothing about a
* 120Hz phone. Counts do not have that problem: how many times a row was composed, or a reply
* parsed, is the same number on any machine, and it is the number that says whether the work is
* proportional to what is on screen or to everything ever loaded.
* is under the floor of what it can measure. Counts do not have that problem: how many times a row
* was composed, or a reply parsed, is the same number on any machine, and it is the number that
* says whether the work is proportional to what is on screen or to everything ever loaded.
*
* Always on rather than behind a build flag. What is measured is an atomic increment on paths that
* already allocate lists and parse markdown, and a counter that is only compiled into the build
@@ -90,14 +89,12 @@ object DebugStats {
*
* The draw phase is where Compose's measurement lands as well as its recording -- the platform
* calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three
* different things is high. The transcript times its own measure, its own placement and its own
* recording, and this is the subtraction that was otherwise done by hand in a conversation every
* time a report arrived. What is left over is the framework's per-frame bookkeeping after a layout,
* which grows with how many nodes are alive rather than with how many are on screen.
* different things is high. The transcript times its own measure, placement and recording, and this
* is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout,
* which grows with how many nodes are alive rather than how many are on screen.
*
* Per frame rather than in total, because the budget it has to fit in is per frame. The recordings
* are not themselves per-frame -- a measurement happens on the frames that need one -- so these are
* shares of an average frame, not a claim about any particular one.
* are not themselves per-frame, so these are shares of an average frame.
*/
fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
if (frames == 0 || drawNanos == 0L) return emptyList()
@@ -122,8 +119,7 @@ fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
* frames went, and what the app did to produce them.
*
* Written for somebody to paste into a conversation, so it is plain text with the units on every
* number -- a report whose reader has to ask what the columns mean costs another round trip, and
* the whole point of it is to save one.
* number -- a report whose reader has to ask what the columns mean costs another round trip.
*/
fun debugReport(
device: String,
@@ -21,11 +21,7 @@ import androidx.compose.ui.unit.dp
* reader scrolling back, both mean "the session no longer has what is above this", and which of the
* two it was is said by the words and the colour.
*
* The rules take [color] too, so the whole divider reads as one mark of one kind rather than a
* coloured phrase sitting in an unrelated grey line.
*
* Written once here rather than styled at each of them, so the two cannot drift into looking like
* different kinds of thing.
* The rules take [color] too, so the whole divider reads as one mark of one kind.
*/
@Composable
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
@@ -44,9 +40,8 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
* The mark a clear leaves.
*
* Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a
* compaction it summarises nothing and measures nothing, so there is nothing to report but the
* fact. Everything above stays on screen and stays scrollable -- the reader can see that, which is
* why this does not say it.
* compaction it summarises nothing and measures nothing. Everything above stays on screen and stays
* scrollable -- the reader can see that, which is why this does not say it.
*/
@Composable
fun ClearedRow(modifier: Modifier = Modifier) {
@@ -10,12 +10,11 @@ private const val DRAFTS = "session-drafts"
*
* On this device rather than on the backend, which is where this app otherwise keeps state so that
* every device sees it. A draft is the case that rule is not about: it is the contents of a text
* box on the phone somebody is holding, written on every keystroke, and half a sentence surfacing
* on another device would be a surprise rather than a convenience. What has been *sent* is the
* server's, and that is the part which has to outlive this phone.
* box on the phone somebody is holding, and half a sentence surfacing on another device would be a
* surprise. What has been *sent* is the server's.
*
* Kept per session id, because the thing being typed belongs to the conversation it is aimed at:
* one shared box would hand a message meant for one session to whichever was opened next.
* Kept per session id: one shared box would hand a message meant for one session to whichever was
* opened next.
*/
fun loadDraft(context: Context, sessionId: String): String =
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty()
@@ -23,11 +22,9 @@ fun loadDraft(context: Context, sessionId: String): String =
/**
* Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep.
*
* The path out is emptying the box, which is what sending does -- so a sent message removes its own
* entry and nothing accumulates for a session in ordinary use. A session *deleted* while it held a
* draft does leave its key behind: pruning those means a pass over the live session list, which
* this file would otherwise have no reason to know about, and the residue is a few bytes per
* session ever abandoned mid-sentence. That is a trade rather than an oversight.
* The path out is emptying the box, which is what sending does. A session *deleted* while it held a
* draft does leave its key behind: pruning those means a pass over the live session list, and the
* residue is a few bytes per session ever abandoned mid-sentence.
*/
fun saveDraft(context: Context, sessionId: String, text: String) {
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit {
@@ -5,13 +5,12 @@ package com.example.aiapp
*
* A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two
* halves, because a short span and a long one are read for different things. Under a minute the
* question is "roughly how long", so only the largest unit is shown and a fraction of it carries
* the rest -- `2.5s`, `30ms`. At a minute or more the question is "how long exactly", so every unit
* that has something in it is written out -- `5d 12h 4m`. Units that are empty are left out rather
* than written as zero, since the labels say which is which and `5d 0h 4m` is only longer.
* question is "roughly how long", so only the largest unit is shown and a fraction carries the rest
* -- `2.5s`. At a minute or more the question is "how long exactly", so every unit with something
* in it is written out -- `5d 12h 4m`. Empty units are left out rather than written as zero.
*
* Sub-second precision is dropped past a minute: nothing that takes days is measured in
* milliseconds, and carrying them would make the common case the widest one.
* milliseconds.
*/
fun formatMillis(ms: Long): String {
if (ms < 0) return "-" + formatMillis(-ms)
@@ -12,7 +12,7 @@ private const val RESET_EVENT = "reset"
*
* The connection and its framing belong to [Sse]; what stays here is what this stream's frames
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
* saw as the new cursor. See SessionScreen.
* saw as the new cursor.
*/
class EventStream(settings: ServerSettings, private val sessionId: String) {
private val stream = Sse(settings)
@@ -24,15 +24,21 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
*
* [onReset] fires when the server answers that the cursor is too far behind to continue from:
* everything already displayed is stale and the events that follow are a fresh window, so the
* caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It
* arrives before those events, so a caller that clears on it stays in order.
* caller drops what it holds and rebuilds. It arrives before those events, so a caller that
* clears on it stays in order.
*/
fun run(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
fun run(
after: Long,
onOpen: () -> Unit,
onReset: () -> Unit,
// The frame's own text as well as the event parsed from it: the transcript cache stores the
// one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit,
) {
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name, so this is one or
// the other.
// A named frame carries no payload and a data frame has no name.
if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(parseSeqEvent(data))
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
}
}
}
@@ -2,10 +2,9 @@ package com.example.aiapp
import org.json.JSONObject
// The common event model, mirrored from server/src/session/driver.rs --
// the app renders purely from this stream (replayed from the transcript by
// cursor, then live), so there is no separate "load history" shape to keep
// in sync with it.
// The common event model, mirrored from server/src/session/driver.rs -- the app renders purely from
// this stream (replayed from the transcript by cursor, then live), so there is no separate "load
// history" shape to keep in sync with it.
/** One transcript line: the event plus its resume cursor and time. */
data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
@@ -13,8 +12,7 @@ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
/**
* One choice offered in answer to a question.
*
* More than a label because the reader is deciding rather than confirming: what an option means,
* and what picking it would produce, are the things that decide it. Both are absent on a
* More than a label because the reader is deciding rather than confirming. Both are absent on a
* permission, whose Allow and Deny mean exactly what they say.
*/
data class QuestionOption(val label: String, val description: String?, val preview: String?)
@@ -30,13 +28,12 @@ sealed class SessionEvent {
*/
val id: String?,
/**
* What was attached to it, by the ref the files route serves: images, and since 2026-09-03
* any file, told apart by [isImageRef].
* What was attached to it, by the ref the files route serves: images, and any file, told
* apart by [isImageRef].
*
* On the message rather than beside it: these arrived as separate image events until
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
* it, and left this app deciding from adjacency alone which message an image went with --
* something the sender knew and could simply have said.
* it, and left this app deciding from adjacency which message an image went with.
*/
val attachments: List<String>,
) : SessionEvent()
@@ -45,11 +42,10 @@ sealed class SessionEvent {
* A message the server has accepted and the session has not read yet.
*
* From the server, not from this app's memory of what it sent. The pending bubble used to be
* screen state, so leaving the session or restarting the app drew nothing waiting while the
* message was still queued -- and nothing waiting is what "there is nothing" looks like.
* screen state, so leaving the session drew nothing waiting while the message was still queued
* -- and nothing waiting is what "there is nothing" looks like.
*
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by
* [CommandSent].
* Resolved by the [UserMessage] carrying the same id.
*/
data class MessageQueued(val id: String, val text: String, val attachments: List<String>) :
SessionEvent()
@@ -59,8 +55,7 @@ sealed class SessionEvent {
*
* Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects
* replays both, and without this one it would put back a bubble for a message that is never
* coming -- with nothing left to resolve it, since the [UserMessage] that normally does is
* exactly what was cancelled.
* coming.
*/
data class MessageDropped(val id: String) : SessionEvent()
@@ -108,17 +103,14 @@ sealed class SessionEvent {
*
* The live Claude Code path only learns a turn was somebody else's when the turn ends, so
* the event arrives below everything it caused; this is what puts it back above it. Null
* for a message read out of a session file, which is already in the right place, and for
* one that started no turn. See the server's `Event::PeerMessage`.
* for a message read out of a session file, and for one that started no turn.
*/
val turnStart: Long? = null,
) : SessionEvent()
/**
* A command the session was asked to run on itself and cannot run yet.
*
* Resolved by [CommandSent] with the same id. A command that ran straight away has only that
* one, so nothing here ever draws a bubble that resolves in the same frame.
* A command the session was asked to run on itself and cannot run yet. Resolved by
* [CommandSent] with the same id; a command that ran straight away has only that one.
*/
data class CommandQueued(val id: String, val text: String) : SessionEvent()
@@ -130,29 +122,26 @@ sealed class SessionEvent {
/**
* What the session is set to, as the session itself reports it.
*
* Either field alone: the two are confirmed separately and by different things. Asking for a
* change is not having one, so this -- not the request -- is what the pickers show.
* Either field alone: the two are confirmed separately. Asking for a change is not having one,
* so this -- not the request -- is what the pickers show.
*/
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
/**
* What a turn cost, and how much the model was holding when it ended.
*
* [context] is prompt plus both cache figures, measured by the backend from the turn's own
* usage. Carried on the event rather than summed by the reader, because it is not a sum: a
* conversation's context drops at a compaction and a clear, so adding turns up would report a
* figure the session stopped being true of. Null where the dialect did not say, and on entries
* recorded before the backend sent it -- which leaves the context unmeasured rather than
* unchanged.
* [context] is prompt plus both cache figures. Carried on the event rather than summed by the
* reader, because it is not a sum: a conversation's context drops at a compaction and a clear,
* so adding turns up would report a figure the session stopped being true of. Null where the
* dialect did not say, which leaves the context unmeasured rather than unchanged.
*/
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
/**
* A compaction that finished, and how much context it recovered.
*
* The counts are nullable because the server sends them only when it was told them: a
* compaction whose size nobody measured has to be able to say so, since a zero here would read
* as "recovered nothing" and a made-up number would read as a measurement.
* The counts are nullable because the server sends them only when it was told them: a zero here
* would read as "recovered nothing" and a made-up number would read as a measurement.
*/
data class Compacted(
val preTokens: Long?,
@@ -163,9 +152,7 @@ sealed class SessionEvent {
/**
* The conversation was cleared. Everything above this is still here to read and is no longer in
* the session's context.
*
* An object rather than a class because it carries nothing: what it means is entirely its
* the session's context. An object rather than a class because what it means is entirely its
* position in the transcript.
*/
data object Cleared : SessionEvent()
@@ -173,17 +160,15 @@ sealed class SessionEvent {
data class Error(val message: String) : SessionEvent()
/**
* An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new
* event kind degrades to a placeholder row instead of killing the stream.
* An event type this app build doesn't know -- a newer server. Kept rather than thrown so one
* new event kind degrades to a placeholder row instead of killing the stream.
*/
data class Unknown(val type: String) : SessionEvent()
}
/**
* A JSON array of strings under [name], empty when the field is absent.
*
* Absent is the ordinary case -- most messages carry no attachment, and the server omits the field
* rather than sending an empty list -- so this is the shape every caller wants.
* A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since
* the server omits the field rather than sending an empty list.
*/
private fun JSONObject.stringList(name: String): List<String> {
val array = optJSONArray(name) ?: return emptyList()
@@ -212,8 +197,8 @@ fun parseSeqEvent(json: String): SeqEvent {
SessionEvent.ToolStart(
id = body.getString("id"),
tool = body.getString("tool"),
// Kept as raw JSON text: the input shape is the tool's own
// business, and the UI only ever shows it verbatim.
// Kept as raw JSON text: the input shape is the tool's own business, and the UI
// only ever shows it verbatim.
input = body.get("input").toString(),
)
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
@@ -282,36 +267,33 @@ fun parseSeqEvent(json: String): SeqEvent {
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
}
/**
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
*
* One predicate because two readers have to agree on the list: the session screen's working
* indicator, and the fold's decision that the newest reply is finished. Two copies would drift the
* first time the server grows a state, and the drift would be a reply that never splits or one
* split mid-stream.
*/
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
/**
* The context after [event], given what it was before.
*
* The same rule the server folds with, because the screen has to keep up between page loads: the
* summary it opened with is a measurement from before this stream started, and every event that
* moves the figure arrives here.
* summary it opened with is a measurement from before this stream started.
*
* The two that lower it are the point. A clear takes the conversation away and a compaction
* replaces it with a summary, so a figure measured before either stopped being true at that moment
* -- and carrying it forward is how a session that had just been cleared went on reporting the
* context it no longer had.
*
* Null is "we don't know", which is a state each of them can reach: nothing measured yet, a
* compaction that finished without saying how much it recovered, or a clear nobody has run a turn
* since.
* Null is "we don't know", which each of them can reach.
*/
/**
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
*
* One predicate because two readers have to agree on the list: the session screen's working
* indicator, and the fold's decision that the newest reply is finished
* ([TranscriptItem.AssistantMsg.settled]). Two copies would drift the first time the server grows a
* state, and the drift would be a reply that never splits or one split mid-stream.
*/
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
fun contextAfter(current: Long?, event: SessionEvent): Long? =
when (event) {
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a
// turn -- which every context figure is -- rather than unknown.
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn
// -- which every context figure is -- rather than unknown.
is SessionEvent.UsageDelta -> event.context ?: current
is SessionEvent.Compacted -> event.postTokens
is SessionEvent.Cleared -> null
@@ -0,0 +1,162 @@
package com.example.aiapp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
/**
* The largest file this app will open in the editor, in bytes.
*
* Measured on the emulator 2026-09-04, in a debug build, on generated Rust:
*
* | file | lines | scan per keystroke | worst frame record | typing |
* |--------|--------|--------------------|--------------------|-------------------|
* | 32 kB | 917 | 10ms | 183ms | sluggish, correct |
* | 128 kB | 3,633 | 40ms | 2,027ms | characters lost |
* | 1 MB | 28,660 | -- | -- | stops responding |
*
* The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file
* costs 40ms a keystroke, which is survivable, while laying the same text out in one
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- what
* EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single
* text field pays that cost. A line-by-line editor is the way past this.
*
* 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays
* the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not.
*/
const val EDIT_LIMIT = 32L * 1024
/**
* The same file, editable, in the same face and colours it was being read in.
*
* `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement
* that colours a field's own text rather than replacing the field with something that only looks
* like one: the transformation returns the text unchanged and the scanner's spans as styles, so
* [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook
* for styles at all.
*
* The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is
* sized against.
*
* The gutter is one `Text` of `1\n2\n…` beside the field rather than a number per row, because
* there are no rows here -- the field is one text object. It lines up for the same reason the
* viewer's does: nothing wraps, so a logical line is a visual line.
*/
@Composable
fun FileEditor(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
language: Language?,
modifier: Modifier = Modifier,
) {
val style = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface)
val scroll = rememberScrollState()
val count = value.text.removeSuffix("\n").count { it == '\n' } + 1
val gutter = gutterWidth(count, style)
val numbers = remember(count) { (1..count).joinToString("\n") }
val transformation =
remember(language) {
VisualTransformation { text ->
TransformedText(highlight(text.text, language), OffsetMapping.Identity)
}
}
Row(verticalAlignment = Alignment.Top, modifier = modifier.fillMaxWidth()) {
Text(
numbers,
style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
softWrap = false,
modifier = Modifier.width(gutter),
)
// The same gap the viewer puts between its numbers and its code, so switching between
// reading and editing does not move the text sideways under the reader.
Spacer(Modifier.width(GUTTER_GAP))
Box(Modifier.horizontalScroll(scroll)) {
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = style,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
visualTransformation = transformation,
)
}
}
}
/**
* What to do about a file that changed on the machine while it was open here.
*
* Three ways out rather than one, and each says what it costs, because there is no answer this app
* can pick on somebody's behalf: an agent editing the same file is the ordinary case here, and both
* versions are somebody's work.
*/
@Composable
fun ConflictDialog(
message: String,
busy: Boolean,
onOverwrite: () -> Unit,
onReload: () -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
// The server's own sentence as the title, rather than a heading of this app's above it
// saying the same thing twice: there is one statement of what happened and it comes from
// the side that found out.
title = { Text(message.replaceFirstChar { it.uppercase() }) },
text = {
Text(
"Overwrite keeps what you typed and loses the other change. " +
"Reload keeps the other change and loses what you typed. " +
"Cancel leaves both alone and keeps you here."
)
},
confirmButton = {
TextButton(onClick = onOverwrite, enabled = !busy) {
Text(if (busy) "Saving..." else "Overwrite")
}
},
dismissButton = {
Row {
TextButton(onClick = onReload, enabled = !busy) { Text("Reload") }
TextButton(onClick = onCancel, enabled = !busy) { Text("Cancel") }
}
},
)
}
/** Leaving an editor with edits in it, which is the one way to lose them by accident. */
@Composable
fun UnsavedDialog(onDiscard: () -> Unit, onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Leave without saving?") },
text = {
Text(
"The edits you have made here will be lost. They have not been written to the machine."
)
},
confirmButton = { TextButton(onClick = onDiscard) { Text("Discard") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Keep editing") } },
)
}
@@ -0,0 +1,122 @@
package com.example.aiapp
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
/**
* A file split into lines, with the highlighter's colours already worked out for each one.
*
* The pure half of the viewer, so it has a JVM unit test and so [of] can run off the main thread:
* scanning a megabyte is work, and doing it inside a composable would do it on the drawing thread
* and again on every recomposition.
*
* Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text
* layout is linear in the text. That means each row needs *its* colours, and the scanner answers in
* offsets into the whole file -- so the spans are bucketed here, once, in one pass.
*/
class FileLines
private constructor(
/** The text of each line, without its newline. */
val lines: List<String>,
/** Per line, the spans that fall in it, with offsets relative to that line's start. */
private val spans: List<List<Span>>,
/**
* The longest line, in character columns -- what the viewer sizes every row to.
*
* Every row has to be the *same* width or they scroll sideways by different amounts; see
* [FileViewer]. Columns rather than measured pixels because the face is monospace, so one
* number and one character's advance give the width of the widest line without measuring twenty
* thousand strings.
*/
val columns: Int,
) {
val size: Int
get() = lines.size
/**
* One line, coloured. Built when the row is composed rather than up front: a file has far more
* lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the
* lazy list exists to avoid.
*/
fun line(index: Int): AnnotatedString {
val text = lines[index]
val here = spans[index]
if (here.isEmpty()) return AnnotatedString(text)
val palette = catppuccinSyntax()
return buildAnnotatedString {
append(text)
here.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
}
}
companion object {
/**
* [text] scanned as [language] and cut into lines.
*
* Exactly one trailing newline is dropped before splitting, so a file that ends the way
* text files are supposed to end has the number of lines its author would count -- `wc -l`
* agrees. Without that, every well-formed file gained a phantom empty last line. An empty
* file is one empty line numbered 1, which is what it is.
*/
fun of(text: String, language: Language?): FileLines =
// Timed, and always, for the reason everything else here is: the cost of opening a
// large file is the number that decides whether the server's size limit is right, and
// an instrument that is only in the build nobody is running answers nothing.
DebugStats.timed("file scanned and cut into lines") {
val body = text.removeSuffix("\n")
val lines = body.split('\n')
val scanned = if (language == null) emptyList() else spansOf(body, language)
FileLines(lines, bucket(lines, scanned), lines.maxOf(::columnsOf))
}
/**
* How many columns a line occupies.
*
* A tab counts as eight rather than one, and deliberately upwards: this decides how far the
* viewer can scroll, and over-estimating leaves a little empty space past the longest line
* where under-estimating makes the end of that line unreachable.
*/
private fun columnsOf(line: String): Int {
var count = 0
for (character in line) count += if (character == '\t') 8 else 1
return count
}
/**
* The scanner's spans, in file offsets, as spans per line in line offsets.
*
* One walk down both lists, which is what the scanner's guarantee buys: its spans come out
* ordered, non-overlapping and inside the text. A span crossing a line break is cut at each
* break and appears in each line it covers, because a row is drawn on its own and cannot
* inherit a colour from the row above.
*/
private fun bucket(lines: List<String>, spans: List<Span>): List<List<Span>> {
val out = ArrayList<List<Span>>(lines.size)
var lineStart = 0
var next = 0
for (line in lines) {
val lineEnd = lineStart + line.length
var here: ArrayList<Span>? = null
// Spans that ended before this line begins are behind the walk for good.
while (next < spans.size && spans[next].end <= lineStart) next++
var at = next
while (at < spans.size && spans[at].start < lineEnd) {
val span = spans[at]
val start = maxOf(span.start, lineStart) - lineStart
val end = minOf(span.end, lineEnd) - lineStart
if (end > start) {
(here ?: ArrayList<Span>().also { here = it }).add(
Span(start, end, span.kind)
)
}
at++
}
out.add(here ?: emptyList())
// The newline itself, which is in the text and not in any line.
lineStart = lineEnd + 1
}
return out
}
}
}
@@ -0,0 +1,242 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */
@Composable
fun codeStyle(): TextStyle =
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
/**
* [content] scanned off the main thread, then drawn.
*
* Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660
* lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first
* written, that is 460ms of frozen screen at the size the server is willing to send -- long enough
* that the accessibility tree cannot be read, which is what "the app has stopped" looks like.
*
* Keyed on the text and the language, so re-reading the same file does not rescan it.
*/
@Composable
fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) {
var lines by remember(content, language) { mutableStateOf<FileLines?>(null) }
LaunchedEffect(content, language) {
lines = withContext(Dispatchers.Default) { FileLines.of(content, language) }
}
when (val ready = lines) {
null -> CircularProgressIndicator(Modifier.padding(8.dp))
else -> FileViewer(ready, modifier)
}
}
/**
* A file, one line per row, coloured by the same scanner that colours a reply's code fences.
*
* A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is
* that each row needs its own colours, which is what [FileLines] works out once and off this
* thread.
*
* Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a
* block and a long line does not silently become three -- which would put the gutter's numbers
* against the wrong text.
*
* **Every row is given the same content width**, and that is what makes the shared scroll state
* behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset
* into *its own* range -- `content width - viewport` -- so with rows of their natural widths a
* short line's range is zero and it never moves while a long one beside it does. Each row also
* writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever
* row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on
* 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range
* looks like.
*
* The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box
* around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise,
* so only the line under the finger stretched. Only possible because every row now has the same
* range.
*
* The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the
* numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width
* is measured from the digit count of the line count in the style it is drawn in.
*
* Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and
* copying it gives the code rather than the code with a number in front of every line.
*/
@Composable
fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
val style = codeStyle()
val scroll = rememberScrollState()
val overscroll = rememberOverscrollEffect()
val rows = rememberLazyListState()
val gutter = gutterWidth(lines.size, style)
val content = contentWidth(lines.columns, style)
Box(modifier.fillMaxSize()) {
// One container around the whole file rather than one per line, so a selection can run
// across lines -- the same arrangement the transcript uses.
SelectionContainer {
// The stretch is drawn here, once, over everything this box holds; the rows below only
// feed it. `clipToBounds` because a stretch draws outside the box it came from.
Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) {
LazyColumn(state = rows, modifier = Modifier.fillMaxSize()) {
items(lines.size) { index ->
Row(verticalAlignment = Alignment.Top) {
// Where the numbers go, drawn from outside this box.
Spacer(Modifier.width(gutter + GUTTER_GAP))
Text(
lines.line(index),
style = style,
softWrap = false,
// The scroll outside the width: the scrolling node's viewport is
// what the row has room for, and its content is the whole file's
// widest line. The shared effect is given to every row and rendered
// by none of them -- see the box above.
modifier =
Modifier.horizontalScroll(scroll, overscroll).width(content),
)
}
}
}
}
}
LineGutter(rows, gutter, style)
}
}
/**
* The line numbers, drawn beside the file rather than in it.
*
* They have to be outside the box the stretch is rendered on, or they bend with the text; and they
* have to stay exactly level with the lines they number. Those two pull in opposite directions.
*
* A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come
* from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during
* measurement, so this composes from the answer the list has just produced rather than one it read
* a frame ago. A `Column` translated by the scroll position could not: the translation would be
* current while the set of numbers was a composition behind, so during a fling the numbers would
* slide against their lines.
*
* The list is measured before this is -- they are siblings in a `Box` and it is declared first.
*
* `onSurfaceVariant`, because a number is not part of the file. The background is painted because
* the stretch can carry the text sideways under this column, and a digit with a smear of code
* behind it reads as a rendering fault.
*/
@Composable
private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
val colour = MaterialTheme.colorScheme.onSurfaceVariant
val surface = rawSurface
SubcomposeLayout(Modifier.fillMaxHeight().width(width).background(surface).clipToBounds()) {
constraints ->
val visible = rows.layoutInfo.visibleItemsInfo
val numbers = visible.map { item ->
subcompose(item.index) {
Text(
(item.index + 1).toString(),
style = style,
color = colour,
textAlign = TextAlign.End,
maxLines = 1,
)
}
.first()
.measure(Constraints.fixedWidth(constraints.maxWidth))
}
layout(constraints.maxWidth, constraints.maxHeight) {
numbers.forEachIndexed { index, number -> number.place(0, visible[index].offset) }
}
}
}
/**
* How wide the widest line number is, measured rather than guessed.
*
* `9` repeated, because digits in a monospace face are all one width -- what matters is how many
* there are. Measuring in the style the numbers are drawn in is what makes this survive a font
* size, a density or a display scale nobody here chose.
*/
@Composable
fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val digits = maxOf(1, lineCount.toString().length)
return remember(digits, style, density) {
with(density) {
measurer.measure(AnnotatedString("9".repeat(digits)), style).size.width.toDp()
}
}
}
/**
* How wide to make every row: the widest line in the file, in this style.
*
* One character measured rather than the line itself, because the face is monospace and measuring
* the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already
* has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of
* rounding.
*
* Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary
* one: a minified file is a single line of a hundred thousand characters, and laying that out as
* one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be
* reached, which is the tolerable half of that trade.
*/
@Composable
private fun contentWidth(columns: Int, style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(columns, style, density) {
val advance = measurer.measure(AnnotatedString("0".repeat(64)), style).size.width / 64f
with(density) { (columns * advance).coerceAtMost(MAX_CONTENT_PX).toDp() }
}
}
/**
* The widest a row may be laid out, in pixels. Well under what `Constraints` can carry, and far
* past any line anybody reads.
*/
private const val MAX_CONTENT_PX = 100_000f
/**
* The space between the numbers and the code. A gap, not an alignment: the two are already aligned
* by the row, and this is only so the digits and the first character are not touching.
*/
val GUTTER_GAP = 8.dp
@@ -0,0 +1,688 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Which machine's files to show, and where to start.
*
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
* where it was working. That is what makes a second way in -- from the setups tab -- one more
* caller rather than any new code here.
*/
data class FilesTarget(val setup: String, val setupName: String, val start: String)
/** Where the explorer is: in a directory, or in one file. */
private sealed class Spot(val path: String) {
class Dir(path: String) : Spot(path)
class Doc(path: String) : Spot(path)
}
/**
* The files on the machine a session runs on: browse them, read one, change one.
*
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
* flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from
* where it opened.
*
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
* how one gets asked again on purpose, and creating something refetches the directory it was
* created in.
*/
@Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
val scope = rememberCoroutineScope()
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below,
// because they are what back has to know about -- and back arrives from two places, the arrow
// and the platform's own gesture, which must mean the same thing.
var editing by remember { mutableStateOf(false) }
var dirty by remember { mutableStateOf(false) }
var askUnsaved by remember { mutableStateOf(false) }
val here = stack.last()
fun go(spot: Spot) {
editing = false
dirty = false
stack = stack + spot
}
fun back() {
when {
editing && dirty -> askUnsaved = true
editing -> editing = false
stack.size > 1 -> {
stack = stack.dropLast(1)
editing = false
dirty = false
}
else -> onClose()
}
}
suspend fun load(path: String, again: Boolean) {
if (!again && listings[path] is LoadState.Loaded) return
listings[path] = LoadState.Loading
listings[path] =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchDir(settings, target.setup, path))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
BackHandler(onBack = ::back)
Box(
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
// The session under this deliberately takes no keyboard inset, so the explorer adds its
// own -- otherwise the editor types under the keyboard.
.imePadding()
) {
Column(Modifier.fillMaxSize()) {
when (val spot = here) {
is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading
// The resolved path once there is one: a directory opened as `~` is called what
// it turned out to be, not what it was asked for.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
FilesHeader(
title = baseName(at),
path = at,
machine = target.setupName,
onBack = ::back,
) {
GlyphButton(
REFRESH_GLYPH,
"Refresh this directory",
{ scope.launch { load(spot.path, again = true) } },
enabled = state !is LoadState.Loading,
)
GlyphButton(
PLUS_GLYPH,
"Create here",
{ creating = true },
enabled = state is LoadState.Loaded,
)
}
LaunchedEffect(spot.path) { load(spot.path, again = false) }
DirectoryBody(state, onOpen = ::go)
}
is Spot.Doc ->
DocPane(
settings = settings,
target = target,
path = spot.path,
name = baseName(spot.path),
editing = editing,
onEditing = { editing = it },
onDirty = { dirty = it },
onBack = ::back,
)
}
}
}
if (askUnsaved) {
UnsavedDialog(
onDiscard = {
askUnsaved = false
editing = false
dirty = false
},
onCancel = { askUnsaved = false },
)
}
val dir = here as? Spot.Dir
val listing = (listings[dir?.path] as? LoadState.Loaded)?.value
if (creating && dir != null && listing != null) {
CreateDialog(
settings = settings,
setup = target.setup,
directory = listing.path,
onDismiss = { creating = false },
onCreated = { path, isDirectory ->
creating = false
scope.launch {
// The directory it was created in is the one thing that changed, so that is
// what gets asked again -- not the whole stack.
load(dir.path, again = true)
// A new file has nothing to look at, so it opens where it can be filled in.
if (!isDirectory) {
go(Spot.Doc(path))
editing = true
}
}
},
)
}
}
/**
* The row every view in here has at the top: back, what this is, and what acts on it.
*
* The path is truncated in the middle when it will not fit, because both ends carry something the
* reader needs -- the machine and the top of the tree at one end, the file at the other -- and it
* is the longest paths, the ones being read most closely, that get cut.
*/
@Composable
private fun FilesHeader(
title: String,
path: String,
machine: String,
onBack: () -> Unit,
actions: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
Text(
"$machine · $path",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
Row { actions() }
}
}
/**
* What is in a directory.
*
* A listing that failed says why, in the machine's own words, where the rows would be -- never an
* empty list, which is what "there is nothing here" looks like and is the one wrong answer that
* looks like a right one.
*/
@Composable
private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
when (state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
is LoadState.Error ->
Text(
state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp),
)
is LoadState.Loaded -> {
val listing = state.value
val sorted = remember(listing) { sortForDisplay(listing.entries) }
LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
parentOf(listing.path)?.let { parent ->
item("..") {
EntryRow(
glyph = FOLDER_GLYPH,
name = "..",
trailing = null,
onClick = { onOpen(Spot.Dir(parent)) },
)
}
}
if (sorted.isEmpty()) {
item("empty") {
Text(
"Nothing here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
uniqueItems(sorted, key = { it.name }) { entry ->
val path = join(listing.path, entry.name)
EntryRow(
glyph = if (entry.isDirectory) FOLDER_GLYPH else FILE_GLYPH,
name = entry.name,
trailing = trailingOf(entry),
onClick = {
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
},
)
}
}
}
}
}
/**
* What a row says after the name, or nothing.
*
* A symlink says so instead of giving a size, because the size a listing reports for one is the
* length of the path it points at -- a number that looks exactly like a file size and is about
* something else. `other` covers a fifo, a device, and a link whose target is gone: the row still
* appears, because a directory that hid what it held would be lying about being empty.
*/
private fun trailingOf(entry: DirEntry): String? =
when {
entry.link -> "link"
entry.isDirectory -> null
entry.kind == "file" -> humanSize(entry.size) ?: "0 B"
else -> "other"
}
@Composable
private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Glyph(glyph, colour = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(12.dp))
Text(
name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.weight(1f),
)
trailing?.let {
Spacer(Modifier.width(8.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* One file: read, and edited behind the pencil.
*
* Its own composable so that everything about one file -- what came back, what has been typed, and
* whether a save is out -- is remembered under that file's path and thrown away when the reader
* moves to another. What is *not* here is edit mode itself: back has to know about it.
*/
@Composable
private fun ColumnScope.DocPane(
settings: ServerSettings,
target: FilesTarget,
path: String,
name: String,
editing: Boolean,
onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var state by remember(path) { mutableStateOf<LoadState<FileContent>>(LoadState.Loading) }
var draft by remember(path) { mutableStateOf(TextFieldValue()) }
var saving by remember(path) { mutableStateOf(false) }
var saveError by remember(path) { mutableStateOf<String?>(null) }
var conflict by remember(path) { mutableStateOf<String?>(null) }
// The editor's own vertical scroll, hoisted so the gutter and the text move together: they are
// two composables in one row, and a scroll inside either would leave the other behind.
val editScroll = rememberScrollState()
val language = remember(name) { fileLanguage(name) }
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text
// Readable but not editable: see [EDIT_LIMIT]. The size is the one the machine reported, so
// this is decided before anything is typed rather than discovered by a keyboard that stops
// answering.
val editable = loaded != null && loaded.size <= EDIT_LIMIT
suspend fun fetch() {
state = LoadState.Loading
state =
try {
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
if (got is FileContent.Text) draft = TextFieldValue(got.content)
LoadState.Loaded(got)
} catch (e: ApiException) {
LoadState.failed(e)
}
onDirty(false)
}
LaunchedEffect(path) { fetch() }
val changed = loaded != null && draft.text != loaded.content
LaunchedEffect(changed) { onDirty(changed) }
/** Writes the draft back, [against] being the digest it is allowed to replace. */
fun save(against: String) {
if (saving) return
saving = true
saveError = null
scope.launch {
try {
val written =
withContext(Dispatchers.IO) {
writeFile(settings, target.setup, path, draft.text, against)
}
state =
LoadState.Loaded(
FileContent.Text(
path,
written.size,
written.modified,
written.sha256,
draft.text,
)
)
conflict = null
onDirty(false)
onEditing(false)
} catch (e: ApiException) {
// The one refusal that is a question rather than a message: somebody else's edit is
// on the machine, and which of the two survives is not this app's to decide.
if (e.status == 409) conflict = e.message ?: "It changed on the machine."
else saveError = e.message
} finally {
saving = false
}
}
}
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
if (editing) {
if (saving) {
GlyphSpinner("Saving")
} else {
GlyphButton(
SAVE_GLYPH,
"Save",
{ loaded?.let { save(it.sha256) } },
// Disabled rather than hidden while there is nothing to write: a button that
// comes and goes makes its own absence the signal.
enabled = changed,
)
}
} else {
GlyphButton(
REFRESH_GLYPH,
"Read this file again",
{ scope.launch { fetch() } },
enabled = state !is LoadState.Loading,
)
GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable)
}
}
saveError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
// Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it
// is disabled -- and a reader who cannot edit a file they can plainly read will otherwise
// conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button
// never gets.
if (loaded != null && !editable) {
Text(
"Too big to edit here (${humanSize(loaded.size)}; the limit is " +
"${humanSize(EDIT_LIMIT)}). A text field this large stops answering the keyboard.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
is LoadState.Error ->
Text(
current.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
)
is LoadState.Loaded ->
when (val file = current.value) {
is FileContent.Text ->
if (editing) {
FileEditor(
draft,
{ draft = it },
language,
Modifier.verticalScroll(editScroll),
)
} else {
ScannedFile(file.content, language)
}
// Said in words, with the measurement that makes it make sense. Neither of
// these is an empty file and neither is an error, so neither may look like one.
is FileContent.Binary ->
Note(
"This is not text (${humanSize(file.size) ?: "0 B"}), so there is nothing to show."
)
is FileContent.TooBig ->
Note(
"This file is ${humanSize(file.size)}, which is more than the server will " +
"send. Nothing was read, so nothing here is a sample of it."
)
}
}
}
conflict?.let { message ->
ConflictDialog(
message = message,
busy = saving,
onOverwrite = {
// Re-read only to learn what it hashes to *now*, which is the digest an overwrite
// has to be allowed against. The content is deliberately thrown away: overwriting
// is the choice to lose it.
scope.launch {
val fresh =
try {
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
} catch (e: ApiException) {
saveError = e.message
conflict = null
return@launch
}
if (fresh is FileContent.Text) save(fresh.sha256)
else {
saveError =
"It is no longer a text file, so this app will not write over it."
conflict = null
}
}
},
onReload = {
conflict = null
scope.launch { fetch() }
},
onCancel = { conflict = null },
)
}
}
/** A sentence where the file's content would be, for the two states that have no content. */
@Composable
private fun Note(text: String) {
Text(
text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(8.dp),
)
}
/**
* Naming one thing in the directory that is open.
*
* A name and a switch, not a name and a body: the editor is where content is typed, and a modal
* with a text area in it is a second editor to keep in step with the first. A created file opens
* straight into edit mode, because an empty file is not something to look at.
*/
@Composable
private fun CreateDialog(
settings: ServerSettings,
setup: String,
directory: String,
onDismiss: () -> Unit,
onCreated: (String, Boolean) -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var isDirectory by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
fun create() {
val chosen = name.trim()
if (busy || chosen.isEmpty()) return
busy = true
error = null
val path = join(directory, chosen)
scope.launch {
try {
withContext(Dispatchers.IO) {
if (isDirectory) createDir(settings, setup, path)
else createFile(settings, setup, path)
}
onCreated(path, isDirectory)
} catch (e: ApiException) {
// Beside the button that caused it: this dialog is the only thing on screen that
// knows something was being created, and the reason is usually the name itself.
error = e.message
busy = false
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create in ${baseName(directory)}") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Directory", modifier = Modifier.weight(1f))
Switch(
checked = isDirectory,
onCheckedChange = { isDirectory = it },
enabled = !busy,
)
}
Text(
"A name that is already taken is refused rather than replaced.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
TextButton(onClick = { create() }, enabled = !busy && name.isNotBlank()) {
Text(if (busy) "Creating..." else "Create")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } },
)
}
/**
* Directories first, then by name ignoring case, and stably.
*
* Sorted here rather than by the machine: presentation order is a display decision, and `find`
* answers in whatever order the directory happens to be stored in. Dotfiles are not hidden -- in a
* repository they are half of what matters.
*/
internal fun sortForDisplay(entries: List<DirEntry>): List<DirEntry> =
entries.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
/**
* The directory above [path], or null at the root.
*
* A string operation on a path the *machine* resolved, which is what makes it safe: every listing
* answers with its own `pwd -P`, so there is never a `..` or a symlink left in here to reason
* about, and this app never has to resolve one.
*/
internal fun parentOf(path: String): String? {
val trimmed = path.trimEnd('/')
if (trimmed.isEmpty()) return null
val cut = trimmed.lastIndexOf('/')
return when {
cut < 0 -> null
cut == 0 -> "/"
else -> trimmed.substring(0, cut)
}
}
/** What a path names: its last segment, with `/` naming itself. */
internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/')
return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/')
}
internal fun join(directory: String, name: String): String =
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"
@@ -19,19 +19,15 @@ import androidx.compose.ui.platform.LocalContext
* The point of splitting it up is that "the scroll is laggy" has two completely different causes
* and one appearance. If the layout-and-measure and draw figures are small and the total is large,
* the time is going into rasterising and compositing, and no amount of doing less work per row will
* move it. If they are large, the work per row is the problem and it is ours to fix. Guessing
* between those two is how a day gets spent rewriting the half that was already fast.
* move it. If they are large, the work per row is the problem and it is ours to fix.
*
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
* broken down into the parts the UI thread is responsible for -- handling input, running
* animations, measuring and laying out, recording the draw -- and the parts after it.
* broken into the parts the UI thread is responsible for and the parts after it.
*
* One of these for the app, like [DebugStats], because the two are read as one report and
* [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session
* and the counters were not, so a report copied after visiting two sessions divided every session's
* work by the newest one's frame count -- and printed the result as a per-frame measurement. It
* said 36.8 seconds of placement inside a 13.5 second window, and left "everything else" clamped at
* 0.00ms (0%), which reads as a screen whose whole cost is this app's own code.
* work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window.
*/
object FrameStats {
private val total = ArrayList<Long>()
@@ -54,7 +50,7 @@ object FrameStats {
total += metrics.getMetric(FrameMetrics.TOTAL_DURATION)
// How long the frame waited for the UI thread to be free before it could start. Reported
// because the phases otherwise do not add up to the total, and the gap is the interesting
// part: it is the frame being held up by work that is not the frame's.
// part: the frame being held up by work that is not the frame's.
waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)
animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION)
@@ -123,7 +119,7 @@ private const val CAP = 20_000
* Records into [FrameStats] for as long as this screen is on it.
*
* The listener is what comes and goes; what it writes into does not, so a report covers the same
* stretch of time as the counters beside it. See [FrameStats].
* stretch of time as the counters beside it.
*
* The listener is handed its own thread because the platform calls it for every frame and the
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
@@ -47,12 +47,11 @@ data class SyntaxPalette(
/**
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
*
* Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the same
* code is the same colours wherever it appears.
* Shared by a tool call's input and a reply's fences, so the same code is the same colours wherever
* it appears.
*
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
* off the drawing thread: the syntax palette is fixed, and a fence with no language is plain text
* which needs no colour of its own -- the style the caller draws it with carries that.
* off the drawing thread.
*
* The timing is the number the highlighter is judged by: the library this replaced took **174ms**
* on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted]
@@ -60,7 +59,7 @@ data class SyntaxPalette(
*/
fun highlight(code: String, language: Language?): AnnotatedString {
if (language == null) return AnnotatedString(code)
val spans = DebugStats.timed("code highlighted") { scan(code, rulesOf(language)) }
val spans = DebugStats.timed("code highlighted") { spansOf(code, language) }
val palette = catppuccinSyntax()
return buildAnnotatedString {
append(code)
@@ -82,8 +81,7 @@ fun highlight(code: String, language: Language?): AnnotatedString {
* to the end of the code, which is also what it looks like while a fence is still being written.
*
* In ordinary code the order of recognition is comment, string, attribute, number, word, and
* finally a single punctuation or mark character. Punctuation and marks are coloured only in
* ordinary code, never inside a string or a comment.
* finally a single punctuation or mark character, which are coloured only in ordinary code.
*/
fun scan(code: String, rules: Rules): List<Span> = Scanner(code, rules).run()
@@ -156,8 +154,8 @@ private class Scanner(private val code: String, private val rules: Rules) {
at += comment.open.length
var depth = 1
while (at < code.length && depth > 0) {
// The closer is tried first so that a language whose two delimiters are the same
// string -- CoffeeScript's `###` -- closes rather than nesting forever.
// The closer is tried first so that a language whose two delimiters are the same string
// -- CoffeeScript's `###` -- closes rather than nesting forever.
if (starts(comment.close)) {
depth--
at += comment.close.length
@@ -49,11 +49,9 @@ private const val DELETING = "deleting"
/**
* What the rows further down a batch say while they wait their turn.
*
* Its own word rather than the operation's, because it is its own state and the difference is the
* kind that matters: nothing has been done to this session yet, so a batch stopped here leaves it
* exactly as it was. Marked from the moment the batch is handed over all the same -- a queued row
* that still looked ordinary was still tappable, and tapping it would import it a second time
* behind the batch already coming for it.
* Its own word rather than the operation's, because nothing has been done to this session yet, so a
* batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over
* all the same -- a queued row that still looked ordinary was still tappable.
*/
private const val WAITING = "waiting"
@@ -62,25 +60,22 @@ private const val WAITING = "waiting"
*
* A batch takes rows out of the list as each one lands, so everything below the one that went
* slides up -- and a tap already on its way then arrives at whichever row moved into that place. On
* this screen that means importing a session nobody chose, which is not something a second tap can
* undo.
* this screen that means importing a session nobody chose.
*
* Swallowed silently rather than shown, because anything drawn on every row a batch passes would be
* a flicker running down the list. Half a second: long enough to cover a tap already travelling
* when the row moved, short enough that it is not in the way of a deliberate one.
* a flicker running down the list.
*/
private const val SETTLE_MS = 500L
/**
* Continuing a Claude Code session the machine already has.
*
* The list is the machine's answer, not this app's: it asks a setup what sessions it holds and
* shows them. Choosing one sends its **id**, never a path, so an enrolled phone cannot turn this
* screen into a file reader.
* The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so
* an enrolled phone cannot turn this screen into a file reader.
*
* Holding a row selects it and puts the screen in selection mode, where the options that act on a
* selection appear along the bottom. That exists because these arrive in bulk a machine
* accumulates dozens of abandoned sessions and one confirmation dialog per row is the reason
* selection appear along the bottom. That exists because these arrive in bulk -- a machine
* accumulates dozens of abandoned sessions -- and one confirmation dialog per row is the reason
* clearing them out was not worth doing.
*/
@OptIn(ExperimentalFoundationApi::class)
@@ -91,28 +86,25 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
var chosen by remember { mutableStateOf<Setup?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
// What is happening to each row right now, as the word the row shows: "importing" or
// "deleting". A map keyed by id rather than a flag per row, because the rows are rebuilt from
// whatever the server last said and this belongs to the request rather than to the session --
// the same arrangement the session list uses for its deletes.
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
// than a flag per row, because the rows are rebuilt from whatever the server last said and this
// belongs to the request rather than to the session.
var running by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which rows the reader has picked out. Empty means selection mode is off: there is no
// separate flag, because a selection mode with nothing selected is a state with no controls
// in it and no way to leave except Back.
// Which rows the reader has picked out. Empty means selection mode is off: a selection mode
// with nothing selected is a state with no controls in it and no way to leave except Back.
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
// Failures that belong to one row rather than to the screen, shown on that row. A batch is
// exactly where a single banner fails: nine deletes succeeded and one did not, and the
// banner cannot say which.
// exactly where a single banner fails: nine deletes succeeded and one did not, and the banner
// cannot say which.
var rowErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
// themselves, not a flag, so the dialog can say what it is about.
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
// Same default as the spawn screen, and for the same reason: a phone
// is the wrong place to answer "allow Bash?" forty times.
// Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty
// times.
var permissionMode by remember { mutableStateOf("auto") }
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from
// it, so a tap reading it needs no recomposition and there is no timer to cancel when a
// second removal lands on top of the first.
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
// so a tap reading it needs no recomposition.
val movedAt = remember { mutableMapOf<String, Long>() }
fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS
@@ -120,8 +112,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Fetches the list and takes the row states from it.
*
* Taken from the answer rather than kept across the load: the server is what knows what is
* running, and this screen may be opening on work another screen -- or another phone --
* started. Anything held locally would be a second version of that, and the stale one.
* running, and this screen may be opening on work another phone started.
*/
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
try {
@@ -167,13 +158,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Hands [targets] to the server in one request, marking every row it covers.
*
* The request only *starts* the work -- the server runs it and says how each row went on the
* change stream, which is what lets this screen be left while a batch is still going. So there
* is nothing here to wait for and nothing to sequence: the rows are marked, the batch goes, and
* everything after that arrives as an event.
* change stream, which is what lets this screen be left while a batch is still going.
*
* Marked [WAITING] rather than with the operation's own word until the server confirms. Between
* the request leaving and the `started` event coming back, "we have asked" is the truth and "it
* is importing" is a guess -- and the row is inert either way, which is the part that matters.
* is importing" is a guess.
*
* The selection is dropped as the work is handed over, not when it finishes: the screen goes
* back to how it started, and what says the work is happening is the rows it is happening to.
@@ -186,17 +175,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
val ids = targets.map { it.id }
scope.launch {
// One request for the whole batch, not one per row. Sent row by row, a handover was
// only as atomic as the network: the fourth of six could fail, or the screen could be
// left with two still unsent, and what came back was some rows running and some
// untouched -- indistinguishable, on the list, from rows nobody had picked. Now
// either the server has the batch or it has none of it, and this is the one place
// that can be true.
// only as atomic as the network, and what came back was some rows running and some
// untouched -- indistinguishable, on the list, from rows nobody had picked.
try {
withContext(Dispatchers.IO) { send(ids) }
} catch (err: Exception) {
// The server never took it, so nothing is running and no event will arrive to say
// so. This is the one failure the screen must report itself -- and it is now the
// whole batch's failure, which is the point: no row was singled out.
// so. This is the one failure the screen must report itself -- and it is the whole
// batch's failure, which is the point: no row was singled out.
running = running - ids.toSet()
rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" }
return@launch
@@ -205,19 +191,17 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Then ask what actually happened, if anything still looks outstanding.
//
// The change stream is a broadcast with no memory, so an operation that started and
// finished while it was still connecting is one nothing will ever be said about --
// and the row sits marked for ever. That is not hypothetical: with responses held
// back far enough for the stream to open late, one row of a pair of deletes cleared
// and the other stayed on "waiting".
// finished while it was still connecting is one nothing will ever be said about -- and
// the row sits marked for ever. That is not hypothetical: with responses held back far
// enough, one row of a pair of deletes cleared and the other stayed on "waiting".
//
// The listing is the repair, because it carries the same state the events do. Only
// when something still looks outstanding, so the ordinary case -- where the events
// arrived and the rows are already gone -- does not pay for a second listing, which
// is the most expensive call this screen makes.
// The listing is the repair, because it carries the same state the events do. Only when
// something still looks outstanding, so the ordinary case does not pay for a second
// listing, which is the most expensive call this screen makes.
if (setup != null && targets.any { running.containsKey(it.id) }) {
// Quietly: no Loading, because blanking the list to report on rows that are
// already saying what is happening to them is the flicker this screen avoids
// everywhere else.
// Quietly: no Loading, because blanking the list to report on rows that are already
// saying what is happening to them is the flicker this screen avoids everywhere
// else.
sessions = fetchInto(setup)
}
}
@@ -225,13 +209,6 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
/**
* Imports [targets], and goes to the session it made when [thenOpen].
*
* One function for the tap and for the bar, differing in that one flag: continuing a session
* and then looking at it is what a tap on a row means, and a batch has several results and no
* reason to pick one of them to become the screen.
*/
/** Continues [targets] in the background, leaving the screen where it is. */
fun importAll(targets: List<Importable>) {
val setup = chosen ?: return
@@ -283,13 +260,12 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
}
// Live changes to what the server is doing to these sessions, for as long as this screen is
// up. The listing already carried the same state when the screen opened -- this is what keeps
// it current afterwards, including for work another screen or another phone started.
// Live changes to what the server is doing to these sessions, for as long as this screen is up.
// The listing already carried the same state when the screen opened -- this is what keeps it
// current afterwards, including for work another phone started.
//
// Failures here are deliberately quiet. There is nothing for a reader to do about a dropped
// event stream, and nothing is lost by one: every state it would have carried is in the next
// listing, which is what Refresh and re-entering the tab already fetch.
// event stream, and every state it would have carried is in the next listing.
val liveChanges = remember {
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
}
@@ -307,8 +283,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
running =
running + (change.session to (change.operation ?: WAITING))
// Gone from the machine either way: a delete removed the
// transcript, an import made it a session, and neither is
// something this list still has to offer.
// transcript, an import made it a session.
"finished" -> {
running = running - change.session
forget(change.session)
@@ -323,17 +298,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
// The screen leaving, not a failure -- and swallowing it would leave this
// loop reconnecting to a stream nobody is watching.
// The screen leaving, not a failure -- and swallowing it would leave this loop
// reconnecting to a stream nobody is watching.
throw e
} catch (_: Exception) {
// Retried below; the listing is the truth in the meantime.
//
// Any failure, not only an [ApiException]. A stream is an optimisation over
// the listing here, so nothing it can do is worth taking the app down for --
// and catching only the failure that was expected means an unexpected one
// reaches the top of the app and closes it, from a screen that is merely
// loading a list.
// Retried below; the listing is the truth in the meantime. Any failure, not
// only an [ApiException]: a stream is an optimisation over the listing here,
// and catching only the expected failure means an unexpected one closes the app
// from a screen that is merely loading a list.
} finally {
stream.close()
}
@@ -351,9 +323,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Nested inside MainScreen's own handler, so it wins while there is a selection.
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last
// row can still be scrolled to while it is up, and nothing is nudged by a number that was
// right for one font size.
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
// can still be scrolled to while it is up.
var barHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
@@ -428,8 +399,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
}
// Beside nothing in particular, because a selection is not one row: the options that act
// on it belong to the screen, and the bottom is where a thumb already is.
// Beside nothing in particular, because a selection is not one row: the options that act on
// it belong to the screen, and the bottom is where a thumb already is.
if (selected.isNotEmpty()) {
val picked =
(sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
@@ -486,8 +457,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* What can be done to the rows that are selected.
*
* Delete and Import only, for now: they are the two things this screen has ever done to a session,
* and an option that appears here has to work on every row in a selection rather than on the one
* somebody was thinking of.
* and an option that appears here has to work on every row in a selection.
*/
@Composable
private fun SelectionBar(
@@ -567,26 +537,23 @@ private fun ImportableList(
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.combinedClickable(
// Off while something is happening to this row --
// see [BusyItem], which draws that but deliberately
// leaves the gestures alone so the list still
// scrolls.
// Off while something is happening to this row -- see
// [BusyItem], which draws that but leaves the gestures
// alone so the list still scrolls.
enabled = running[session.id] == null,
onClick = {
if (settling(session.id)) return@combinedClickable
// In selection mode a tap is a selection, so the
// reader is never one mis-tap away from starting
// a CLI they were only picking rows for.
// reader is never one mis-tap away from starting a
// CLI they were only picking rows for.
//
// Outside it, a tap continues the session --
// except on a row that cannot be continued,
// where it selects instead. That row's only
// remaining action is Delete, and a tap that
// did nothing at all would be a worse answer
// than one that offers the thing it can do.
// Two `--resume` processes on one transcript
// each replay the other's writes, which is why
// this must not simply try.
// Outside it, a tap continues the session -- except
// on a row that cannot be continued, where it
// selects instead. That row's only remaining action
// is Delete, and a tap that did nothing at all
// would be a worse answer. Two `--resume` processes
// on one transcript each replay the other's writes,
// which is why this must not simply try.
if (selecting || session.inUse == "yes")
onToggle(session)
else onOpen(session)
@@ -606,8 +573,7 @@ private fun ImportableList(
Spacer(Modifier.width(8.dp))
// Beside the title, because "which one was I just in" is
// the question this list answers and the order already
// reflects it -- the reader should be able to see the
// ordering they are being given rather than infer it.
// reflects it.
Text(
relativeTime(session.modified),
style = MaterialTheme.typography.bodySmall,
@@ -616,12 +582,11 @@ private fun ImportableList(
}
Spacer(Modifier.height(4.dp))
// The path first, and the only thing here that is cut: it is
// one long value with no natural break, where the lines below
// it are short enough to wrap readably. Cut at the head,
// because a path is identified by its tail and these all
// share a long prefix. By the row's real width rather than a
// character count, which was one guess for every font size
// and screen.
// one long value with no natural break. Cut at the head,
// because a path is identified by its tail and these all share
// a long prefix. By the row's real width rather than a
// character count, which was one guess for every font size and
// screen.
session.cwd
.takeIf { it.isNotEmpty() }
?.let { cwd ->
@@ -648,8 +613,7 @@ private fun ImportableList(
color = warningColor,
)
}
// Reported where it happened, in the server's own words, the
// way every other failure in this app is shown.
// Reported where it happened, in the server's own words.
errors[session.id]?.let { message ->
Spacer(Modifier.height(4.dp))
Text(
@@ -667,29 +631,20 @@ private fun ImportableList(
}
}
/** A byte count at the coarsest unit that still says something, so rows stay comparable. */
private fun humanSize(bytes: Long): String? =
when {
bytes <= 0L -> null
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
bytes >= 1_000L -> "${bytes / 1_000L} kB"
else -> "$bytes B"
}
/** What this session is: the measurements, in the order they are worth knowing. */
private fun statsOf(session: Importable): String =
listOfNotNull(
// Said, because a name and a last message are different claims: one describes the
// session, the other is only what happened last in it.
if (session.named) "named" else null,
// What continuing it costs, which is the question this list is really asked. First
// of the measurements for that reason, and absent rather than zero when nothing has
// been measured -- a session with no turns yet has no figure, not a figure of none.
// What continuing it costs, which is the question this list is really asked. Absent
// rather than zero when nothing has been measured -- a session with no turns yet has no
// figure, not a figure of none.
session.contextTokens?.let { "${it / 1000}k context" },
"${session.lines} lines",
// Kept beside the context figure because the two disagree usefully: most of a large
// transcript is history from before a compaction, which the model is no longer
// given, so a big file can be cheap to continue and a small one expensive.
// transcript is history from before a compaction, so a big file can be cheap to
// continue.
humanSize(session.bytes),
)
.joinToString(" · ")
@@ -698,16 +653,14 @@ private fun statsOf(session: Importable): String =
* Why this session might not be safe to take, if it isn't.
*
* Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind,
* and no shade distinguishes them. The colour is what makes it findable; the words are what make it
* actionable.
* and no shade distinguishes them.
*/
private fun warningOf(session: Importable): String? =
when (session.inUse) {
// What was measured is that a live process on that machine holds this session open. Which
// process is not measured, so it isn't claimed: "a terminal close it there first" sent
// people looking for a window that need not exist. It is just as likely another agent, or
// this app on a session it spawned. Naming a place the reader then can't find turns a
// correct refusal into a wrong instruction.
// process is not measured, so it isn't claimed: "a terminal -- close it there first" sent
// people looking for a window that need not exist. Naming a place the reader then can't
// find turns a correct refusal into a wrong instruction.
"yes" -> "something on that machine is running it"
"unknown" -> "can't tell if it's open"
else -> null
@@ -1,11 +1,14 @@
package com.example.aiapp
/**
* A language the highlighter has rules for.
* A language the highlighter can colour.
*
* The names the reader writes after the backticks are aliases onto these; [fenceLanguage] holds
* that table. A word with no entry there is null, and null is drawn plain, because a fence coloured
* by another language's rules looks highlighted and is wrong in a way the reader cannot see.
*
* Nearly all of them are a row of [RULES], read by one shared scanner. [MARKDOWN] is the one that
* is not; see [spansOf].
*/
enum class Language {
C,
@@ -19,6 +22,7 @@ enum class Language {
JAVASCRIPT,
JSON,
KOTLIN,
MARKDOWN,
PERL,
PHP,
PYTHON,
@@ -45,10 +49,9 @@ data class Rules(
/** Tokens that open a comment running to the end of the line. */
val lineComments: List<String> = emptyList(),
/**
* Whether [lineComments] count only at the start of a word.
*
* The shells need it: `$#`, `${#x}` and `a#b` are not comments, and greying the rest of those
* lines is one of the mistakes this scanner exists to stop.
* Whether [lineComments] count only at the start of a word. The shells need it: `$#`, `${#x}`
* and `a#b` are not comments, and greying the rest of those lines is one of the mistakes this
* scanner exists to stop.
*/
val lineCommentsAtWordStart: Boolean = false,
val blockComment: BlockComment? = null,
@@ -59,8 +62,8 @@ data class Rules(
val rawStrings: Boolean = false,
/**
* Rust: `'` opens a character literal only when a backslash or one character and a `'` follow.
* Otherwise it is a lifetime or a label and no string starts -- without this, `'a` opens a
* string that runs to the next apostrophe in the block.
* Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the
* next apostrophe in the block.
*/
val lifetimes: Boolean = false,
)
@@ -83,8 +86,22 @@ enum class Attributes {
LINE_BRACKET,
}
/** The rules for [language]. */
fun rulesOf(language: Language): Rules = RULES.getValue(language)
/**
* The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to
* be made of.
*
* Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner.
* Markdown has none of those, and what a character means there depends on where on the line it
* sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a
* row of rules or an entry in [SCANNERS], and no caller learns which one it got.
*/
fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(language)(code)
// Lazy for the same reason [RULES] is, since it reads it.
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
mapOf(Language.MARKDOWN to ::scanMarkdown)
}
private val C_STYLE = BlockComment("/*", "*/", nests = false)
private val NESTING = BlockComment("/*", "*/", nests = true)
@@ -121,8 +138,8 @@ private val RULES: Map<Language, Rules> by lazy {
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
),
// `###` opens and closes a block comment and `#` opens a line one, which is why the
// scanner tries the block opener first.
// `###` opens and closes a block comment and `#` opens a line one, which is why the scanner
// tries the block opener first.
Language.COFFEESCRIPT to
Rules(
keywords = KEYWORDS_COFFEESCRIPT,
@@ -143,8 +160,8 @@ private val RULES: Map<Language, Rules> by lazy {
keywords = KEYWORDS_FISH,
lineComments = listOf("#"),
lineCommentsAtWordStart = true,
// fish's single quotes escape only `\'` and `\\`, which is what "skip the
// character after a backslash" already does.
// fish's single quotes escape only `\'` and `\\`, which is what "skip the character
// after a backslash" already does.
quotes = listOf(DOUBLE, SINGLE),
),
Language.GO to
@@ -269,10 +286,9 @@ private val RULES: Map<Language, Rules> by lazy {
* The keyword sets.
*
* Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0
* (`SyntaxTokens.kt`, Apache-2.0), the library this scanner replaced, so that no fence which is
* coloured today turns plain. Entries that are not plain words were dropped -- Kotlin's `as?`,
* `!in` and `!is`, Swift's `#if` family, Ruby's `defined?`, CoffeeScript's `=` and `->` -- because
* the word scanner cannot reach them and the library only matched them by luck.
* (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns
* plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family,
* Ruby's `defined?` -- because the word scanner cannot reach them.
*/
private fun words(list: String): Set<String> =
list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet()
@@ -8,7 +8,7 @@ package com.example.aiapp
* empty list, which is the one wrong answer that looks like a right one.
*
* [Loading] and [Error] carry no payload, so they are `LoadState<Nothing>` and this is covariant in
* [T]: one `LoadState.Loading` serves every screen rather than each needing its own.
* [T]: one `LoadState.Loading` serves every screen.
*/
sealed class LoadState<out T> {
data object Loading : LoadState<Nothing>()
@@ -28,14 +28,13 @@ import androidx.compose.ui.layout.layout
import androidx.core.view.WindowCompat
class MainActivity : ComponentActivity() {
// Bumped whenever enrollment lands via an aiapp:// intent so the
// composition below re-reads the stored settings.
// Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the
// stored settings.
private var settingsVersion by mutableIntStateOf(0)
// The session a notification tap asked for, or null if nothing has. The
// serial is what makes a second tap on the same session's notification a
// second request: without it the two compare equal and the composition
// below has nothing to react to.
// The session a notification tap asked for, or null if nothing has. The serial is what makes a
// second tap on the same session's notification a second request: without it the two compare
// equal and the composition below has nothing to react to.
private var openRequest by mutableStateOf<SessionOpenRequest?>(null)
private var opens = 0
@@ -43,8 +42,8 @@ class MainActivity : ComponentActivity() {
private var shareRequest by mutableStateOf<ShareRequest?>(null)
private var shares = 0
// Registered up front since permission launchers must be registered
// before the activity reaches STARTED.
// Registered up front since permission launchers must be registered before the activity reaches
// STARTED.
private val requestLocalNetworkPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
@@ -52,8 +51,8 @@ class MainActivity : ComponentActivity() {
* The service starts either way, and posts nothing if this is refused.
*
* Deliberately not gated on the answer: the permission can be granted later from Android's own
* settings, and a service that only ever started at the moment it was granted would then stay
* down until the app was launched again -- which is the case notifications exist to avoid.
* settings, and a service that only ever started at the moment it was granted would stay down
* until the app was launched again.
*/
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
@@ -64,21 +63,17 @@ class MainActivity : ComponentActivity() {
// Before anything else that could throw, so the first crash of a launch is caught too.
installCrashLog(this)
// Transparent status bar on every version; the Surface below paints
// through underneath it and content insets itself. Same reasoning
// as dev-updater's MainActivity.
// Transparent status bar on every version; the Surface below paints through underneath it
// and content insets itself. Same reasoning as dev-updater's MainActivity.
enableEdgeToEdge()
// Dark status-bar icons only over a light background, decided from the scheme rather
// than fixed. It was hardcoded to `true` -- dark icons -- which was right against the
// default light surface and became unreadable the moment the app wore Catppuccin Mocha.
// Asking the colour means a future palette change cannot reintroduce that: whatever
// `background` becomes, the icons follow it.
// Dark status-bar icons only over a light background, decided from the scheme rather than
// fixed. It was hardcoded to `true`, which was right against the default light surface and
// became unreadable the moment the app wore Catppuccin Mocha.
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
AiAppColors.background.luminance() > 0.5f
// Android 17+ silently drops local-network traffic without this;
// requested up front because a denial is invisible at the socket
// layer (it just times out).
// Android 17+ silently drops local-network traffic without this; requested up front because
// a denial is invisible at the socket layer (it just times out).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
@@ -88,16 +83,14 @@ class MainActivity : ComponentActivity() {
}
handleIntent(intent)
// After enrollment, so a first launch that arrives with a token
// starts the service with something to connect to rather than
// stopping it and waiting for the next launch.
// After enrollment, so a first launch that arrives with a token starts the service with
// something to connect to rather than stopping it and waiting for the next launch.
NotificationService.sync(this)
setContent {
// Selection colours with the theme rather than at each place text is drawn: the
// transcript is one selection container, and a selection that ran from a reply into
// the code block under it would otherwise change colour halfway. See
// [AiAppSelectionColors].
// transcript is one selection container, and a selection that ran from a reply into the
// code block under it would otherwise change colour halfway.
MaterialTheme(colorScheme = AiAppColors) {
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
Surface(modifier = Modifier.fillMaxSize()) {
@@ -107,8 +100,7 @@ class MainActivity : ComponentActivity() {
// the frame's draw phase is where Compose's measurement lands, and
// a report saying "draw is high" cannot otherwise say whether the
// cost is the transcript or the chrome around it. The keyboard is
// the case that made it matter -- every frame of the IME animation
// relays out and re-records this whole box.
// the case that made it matter.
Modifier.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
@@ -135,19 +127,16 @@ class MainActivity : ComponentActivity() {
}
.fillMaxSize()
.statusBarsPadding()
// The gesture strip at the bottom of most
// phones. Without it the send row sits under
// the swipe area, where a tap is as likely to
// navigate away as to press a button.
// The gesture strip at the bottom of most phones. Without it
// the send row sits under the swipe area, where a tap is as
// likely to navigate away as to press a button.
//
// No imePadding here, deliberately: applied at the root it
// resizes this whole box on every frame of the keyboard
// animation, which re-measures, re-places and re-records every
// screen's entire tree per frame -- measured above as most of
// the frame budget. Each screen takes the keyboard itself
// (AppRoot wraps the ordinary ones; the session screen moves
// only its composer and transcript), so the per-frame cost is
// scoped to what actually moves.
// screen's entire tree per frame. Each screen takes the
// keyboard itself, so the per-frame cost is scoped to what
// actually moves.
.navigationBarsPadding()
) {
AppRoot(settingsVersion, openRequest, shareRequest)
@@ -158,9 +147,8 @@ class MainActivity : ComponentActivity() {
}
}
// launchMode="singleTop": an enrollment scan, or a notification tapped
// while the app is open, lands here rather than in a second activity
// instance.
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
// lands here rather than in a second activity instance.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
@@ -171,8 +159,7 @@ class MainActivity : ComponentActivity() {
*
* Three things arrive this way -- a share from another app, and an `aiapp://` URI that is
* either an enrollment code or a notification naming a session. The URIs are told apart by host
* rather than by two entry points, so a further kind is a branch here rather than another
* intent to remember to handle.
* rather than by two entry points, so a further kind is a branch here.
*/
private fun handleIntent(intent: Intent?) {
intent ?: return
@@ -195,8 +182,8 @@ class MainActivity : ComponentActivity() {
}
saveServerSettings(this, settings)
settingsVersion++
// Enrolling is the moment there is a backend to watch, and
// re-enrolling elsewhere is the moment the old one stops being it.
// Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the
// moment the old one stops being it.
NotificationService.sync(this)
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
}
@@ -29,12 +29,10 @@ import androidx.lifecycle.repeatOnLifecycle
* The app's root: one title, and four views of the backend behind it.
*
* These were four screens reached by four words in a row under the title, and the row was already
* full -- the comment it replaced recorded that a fifth would have to go somewhere else. Tabs say
* the same thing in less space and say one more thing besides: that these are places to be rather
* than errands to run. Sessions, the machine's importable history, the models on it and the
* machines themselves are all *the same backend*, looked at four ways, and none of them is a step
* down from another. Settings still is a step down, which is why it stays a pushed screen and keeps
* its own Back.
* full. Tabs say the same thing in less space and say one more thing besides: that these are places
* to be rather than errands to run. Sessions, the machine's importable history, the models on it
* and the machines themselves are all *the same backend*, looked at four ways, and none is a step
* down from another. Settings still is, which is why it stays a pushed screen with its own Back.
*/
private enum class MainTab(val label: String) {
Sessions("Sessions"),
@@ -61,17 +59,12 @@ fun MainScreen(
//
// What these four draw is a snapshot of a backend they are not connected to, so it is only as
// fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A
// phone that was away while the tunnel was down, or that fetched before the network came up,
// came back to "Couldn't reach the server" sitting at the top of a list the server would now
// answer for perfectly well, and nothing took it off until somebody pressed Refresh. A stale
// failure is worse than a stale list: it is a claim about right now.
// phone that was away while the tunnel was down came back to "Couldn't reach the server"
// sitting at the top of a list the server would now answer for perfectly well. A stale failure
// is worse than a stale list: it is a claim about right now.
//
// Through the same token the Refresh button uses, so this is one instruction the tabs already
// understand rather than a second path into each of them -- which is also what makes it cover
// all four rather than the one the report came from.
//
// Not on the first entry: the tab composing already asks, and bumping here would make every
// cold start fetch twice.
// understand. Not on the first entry: the tab composing already asks.
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
var opening = true
@@ -81,9 +74,8 @@ fun MainScreen(
}
}
// A tab the app put over the list has to step back to it rather than fall through to the
// system default, which closes the app -- that reads as a crash to somebody who only meant to
// get back to their sessions. Nested inside AppRoot's handler, so it wins while it is enabled.
// A tab the app put over the list has to step back to it rather than fall through to the system
// default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled.
BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions }
Column(Modifier.fillMaxSize()) {
@@ -98,20 +90,18 @@ fun MainScreen(
)
// Glyphs rather than the words they replaced: neither ever changes, both are read
// faster than they are spelled, and together they take the width that let the title
// keep its own line. They sit on the title's row because they act on the whole
// screen -- everything below this row is one tab's business, and a control belongs
// with the thing it acts on.
// Flush against each other: a glyph button carries its own padding, so two of them
// side by side already have two rings between their marks and one ring plus this
// row's padding to the screen edge.
// keep its own line. They sit on the title's row because they act on the whole screen.
//
// Flush against each other: a glyph button carries its own padding, so two side by side
// already have two rings between their marks.
Row {
GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ })
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
}
}
// What is waiting to be attached, and what to do about it. Said here because the list
// below is where the choice is made, and a share that arrived with nothing on screen
// saying so would read as a tap that did nothing.
// What is waiting to be attached, and what to do about it. Said here because the list below
// is where the choice is made, and a share that arrived with nothing on screen saying so
// would read as a tap that did nothing.
share?.let {
Text(
it.summary() + " -- open the session it belongs in.",
@@ -127,8 +117,8 @@ fun MainScreen(
.padding(12.dp),
)
}
// Primary rather than the plain TabRow, which is deprecated in favour of the two that
// say where they sit: these are the app's top-level destinations.
// Primary rather than the plain TabRow, which is deprecated in favour of the two that say
// where they sit: these are the app's top-level destinations.
PrimaryTabRow(selectedTabIndex = tab.ordinal) {
MainTab.entries.forEach { entry ->
Tab(
@@ -139,10 +129,9 @@ fun MainScreen(
}
}
// Refreshing means "ask again about what I am looking at", so the button feeds the tab
// that is showing. The token from above means something else already changed what these
// show; the two are the same instruction to the tab below, so they are summed rather than
// tracked apart -- either one moving moves the sum, which is all a tab watches.
// Refreshing means "ask again about what I am looking at", so the button feeds the tab that
// is showing. The token from above means something else already changed what these show;
// the two are the same instruction, so they are summed rather than tracked apart.
val token = reloadToken + refreshToken
when (tab) {
MainTab.Sessions ->
@@ -69,13 +69,10 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
*
* [live] is the reply still arriving, and two things are different for it. Its parse is incremental
* -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole
* message. And its pieces get a layer each: when drawing is invalidated, only the piece that
* changed is re-recorded instead of the whole reply, which is worth a great deal while every delta
* invalidates the message and a finished one can be twenty-five screens tall. It is worth nothing
* once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65
* times in fifty seconds of reading -- and it is not free: each layer is a layout node and a
* display list held for the life of the row, and live node count is what the per-frame cost of the
* transcript scales with.
* message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is
* worth a great deal while every delta invalidates the message and worth nothing once it stops
* changing -- and it is not free: each layer is a layout node and a display list held for the life
* of the row, and live node count is what the transcript's per-frame cost scales with.
*/
@Composable
fun MarkdownText(
@@ -92,8 +89,8 @@ fun MarkdownText(
var previousSegment: Segment? = null
segments.forEachIndexed { at, segment ->
val nextContinues = segments.getOrNull(at + 1)?.continues == true
// Only the tail is still being written; a frozen segment is finished text that
// happens to sit in a live reply, and it takes its colours now. See [MarkdownRoot].
// Only the tail is still being written; a frozen segment is finished text that happens
// to sit in a live reply, and it takes its colours now.
MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) {
segment.pieces.forEachIndexed { index, piece ->
val gap =
@@ -103,9 +100,9 @@ fun MarkdownText(
if (segment.continues) 0.dp else BLOCK_SPACING
else -> gapBefore(previous, piece)
}
// Keyed by where the piece starts in the message rather than by its position
// in this column, so a delta landing in the last block leaves every other
// piece's composition alone -- and a block keeps its key when it freezes.
// Keyed by where the piece starts in the message rather than by its position in
// this column, so a delta landing in the last block leaves every other piece's
// composition alone -- and a block keeps its key when it freezes.
key(segment.start, piece) {
MarkdownPiece(
segment.parse,
@@ -137,8 +134,7 @@ fun MarkdownText(
* A stretch of a message with a parse of its own: the whole of a settled message, or one block, the
* finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins
* in the message. [continues] says the first piece is an item of the list the segment before it
* ended with, so the two draw as one list: no block gap between them, and neither the item above
* the seam nor the one below it takes the padding of a list's edge.
* ended with, so the two draw as one list.
*/
private class Segment(
val text: String,
@@ -154,14 +150,11 @@ private class Segment(
*
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
* height, and the transcript above it collapses and springs back. Seen with five replies on screen
* at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
* later it was all there again. That is the "skipping up and down" this list must never do.
* height, and the transcript above it collapses and springs back -- seen with five replies on
* screen at once, the whole conversation shrunk to fit a single screen.
*
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it
* already has until the new one lands, so there is never a frame without a height. What is on
* screen is always a real prefix of the reply rather than a guess at it; it is simply one parse
* behind.
* already has until the new one lands, so there is never a frame without a height.
*/
@Composable
private fun liveSegments(text: String): List<Segment> {
@@ -187,24 +180,18 @@ private fun liveSegments(text: String): List<Segment> {
* Reparsing the whole message per delta was fine for a short reply and not for a long one: a
* twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran
* off the composing thread it was every core busy while the frame's own thread waited for one.
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished -- nothing appended later can reach back into it, since a paragraph ends at the blank
* line or the block that interrupts it, a fence at its closing fence, a list at the first line that
* is neither an item nor indented under one. So every block but the last is [frozen] with the parse
* that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed
* again.
*
* A list is cut once more, at its last item, by the same reasoning one level down: an item is
* finished once the next item has begun, since a line can only continue the item it is indented
* under or start a new one. Without this a reply that is one long list -- forty sources -- parsed
* the whole list per delta, and a list streams as forty paragraphs would. The item the cut lands on
* has to have begun in earnest: a bare `-` is an empty item now and the first character of a
* paragraph line once `-x` arrives, and cutting on it would draw that line as a new item.
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished -- nothing appended later can reach back into it. So every block but the last is
* [frozen] with the parse that finished it, and only the tail is parsed again.
*
* A list is cut once more, at its last item, by the same reasoning one level down. Without this a
* reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut
* lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of
* a paragraph line once `-x` arrives.
*
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
* it, since the frozen block's parse never sees it. The link draws as its brackets until the reply
* settles and is parsed whole by [warm], which is the same moment every other transient of
* streaming is put right.
* it. The link draws as its brackets until the reply settles and is parsed whole by [warm].
*/
private class LiveParse(
val text: String,
@@ -217,8 +204,8 @@ private class LiveParse(
get() = frozen + tail
fun advanceTo(next: String): LiveParse {
// Anything but an append to what was frozen -- a message replaced, a stream reset --
// starts over.
// Anything but an append to what was frozen -- a message replaced, a stream reset -- starts
// over.
if (!next.regionMatches(0, text, 0, consumed)) return whole(next)
val tailText = next.substring(consumed)
val parse = parseMarkdown(tailText)
@@ -264,8 +251,7 @@ private class LiveParse(
/**
* The piece of the tail still being written: the last item of a list of several, or the first
* piece of the last block when there is more than one block. Null when nothing before it is
* finished, so the tail stays whole.
* piece of the last block when there is more than one. Null when nothing before it is finished.
*/
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
val last = all.lastOrNull() ?: return null
@@ -315,28 +301,24 @@ fun MarkdownPiece(
* The renderer's own environment -- its colours, type scale, dimensions, component table and
* reference links -- around whatever draws pieces of [parse].
*
* The parsing is the library's. Markdown is somebody else's specification, and a hand-written
* The parsing is the library's: markdown is somebody else's specification, and a hand-written
* parser would get the edge cases wrong one case at a time. So is the environment: the element
* composables its dispatch reaches read these locals, and providing them once here is what lets a
* piece be drawn anywhere -- in a message's column, or as one item of the transcript list.
* Everything below this is the mapping onto the app's palette and type scale.
*
* The locals are provided directly rather than through the renderer's `Markdown()` composable,
* which was the last of its composables on the hot path and was here only to provide them. What
* that buys is that nothing between a piece and the screen is the library's but the leaf
* composables named in the component table, so a different parser could stand behind [State]
* without the renderer's entry point being involved.
* which was the last of its composables on the hot path and was here only to provide them. So
* nothing between a piece and the screen is the library's but the leaf composables named in the
* component table.
*
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
* Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its
* own.
*
* [streaming] says this parse is the part of a reply still being written, which only the fences
* care about: lexing is proportional to how much code there is, and a fence still arriving is
* re-lexed at every delta on the composing thread. Measured streaming a two-hundred-line Kotlin
* fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- for colours on
* text that was being replaced as fast as they were computed. So a fence still being written is
* drawn plain and takes its colours when the block freezes, which is the same bargain [LiveParse]
* already makes for a reference link defined at the foot of a message.
* care about: lexing is proportional to how much code there is. Measured streaming a two-hundred-
* line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms --
* for colours on text being replaced as fast as they were computed. So a fence still being written
* is drawn plain and takes its colours when the block freezes.
*/
@Composable
private fun MarkdownRoot(
@@ -354,50 +336,40 @@ private fun MarkdownRoot(
CompositionLocalProvider(
LocalReferenceLinkHandler provides parse.referenceLinkHandler,
LocalMarkdownPadding provides markdownPadding(),
// Read by the renderer's own text composable, which no paragraph reaches any more, and
// by its checkbox. Provided so a path that does reach them draws no image rather than
// failing to compose.
// Read by the renderer's own text composable, which no paragraph reaches any more, and by
// its checkbox. Provided so a path that does reach them draws no image rather than failing
// to compose.
LocalImageTransformer provides remember { NoOpImageTransformerImpl() },
LocalMarkdownAnimations provides markdownAnimations(),
LocalMarkdownColors provides
markdownColor(
text = MaterialTheme.colorScheme.onSurface,
dividerColor = MaterialTheme.colorScheme.outlineVariant,
// The dark surface every verbatim thing in this app sits on -- see [rawSurface],
// and the tool call above this reply, which now matches. `surfaceVariant` was
// exactly a card's own fill, so a fenced block inside a tool call had no
// background at all and one in a reply read as a step *up* out of the page.
// The dark surface every verbatim thing in this app sits on -- and the tool call
// above this reply, which now matches. `surfaceVariant` was exactly a card's own
// fill, so a fenced block inside a tool call had no background at all.
codeBackground = rawSurface,
// The same colour, but let through. 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 hides the selection completely: selecting a sentence highlighted
// every word of it except the ones in backticks, which is a difference in
// appearance the reader has no way to account for. Translucent, the selection
// shows through and the chip still reads as one step down from the page -- there
// is no way to draw it over the selection instead, since the order is the text
// node's.
inlineCodeBackground = rawSurface.copy(alpha = INLINE_CODE_ALPHA),
// The same tint a code block gets, rather than the renderer's 2%-alpha default:
// two adjacent tints that differ by a fiftieth read as one flat block on a phone,
// so the table would have had a border-less grid and nothing saying where it began.
// The same colour. Not drawn by the renderer as a span background but by
// [LinkedText] behind the text, so a selection lands on top of it -- see
// `appendCodeChip`.
inlineCodeBackground = rawSurface,
// The same tint a code block gets, rather than the renderer's 2%-alpha default: two
// adjacent tints that differ by a fiftieth read as one flat block on a phone.
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
),
LocalMarkdownTypography provides
markdownTypography(
// A ladder that starts near the body text and descends, because these are headings
// inside a chat message rather than the top of a document. The renderer's defaults
// are the Material *display* styles -- `#` came out at 57sp and `##` at 45sp, which
// is bigger than this app's own screen titles and reads as the reply shouting.
//
// Every step is a different size, so two levels of nesting never draw the same:
// one clear step per level is the whole job of a heading.
// are the Material *display* styles -- `#` came out at 57sp, bigger than this app's
// own screen titles. Every step is a different size, so two levels of nesting never
// draw the same.
h1 = MaterialTheme.typography.headlineSmall,
h2 = MaterialTheme.typography.titleLarge,
h3 = MaterialTheme.typography.titleMedium,
h4 = MaterialTheme.typography.titleSmall,
h5 = MaterialTheme.typography.labelMedium,
h6 = MaterialTheme.typography.labelSmall,
// Body text at the size everything else in the transcript uses.
text = body,
paragraph = body,
ordered = body,
@@ -405,16 +377,10 @@ private fun MarkdownRoot(
list = body,
table = body,
// Code in a monospace face, in the ordinary text colour. The face and the tinted
// background are what say "this is code"; colour is not, and it used to be green
// -- the palette's colour for a *literal*. A block of code is not a literal, it
// is text that happens to be code, and painting all of it green said the whole
// block was one. Where a literal really does appear inside code, the thing that
// should colour it is a syntax highlighter looking at the code, which is exactly
// what a tool call's input already gets from `catppuccinSyntax`.
//
// The colour rides on the style here rather than in `markdownColor`, which
// stopped carrying `codeText`/`inlineCodeText`/`linkText` when the renderer moved
// them onto the typography.
// background are what say "this is code"; colour is not, and it used to be green --
// the palette's colour for a *literal*. A block of code is not a literal, and
// painting all of it green said the whole block was one. Where a literal really
// does appear inside code, what should colour it is a syntax highlighter.
code =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
@@ -441,31 +407,26 @@ private fun MarkdownRoot(
LocalMarkdownDimens provides
markdownDimens(
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
// the default a fifth of the narrowest column went on space rather than on words
// -- and the narrowest column is where the wrapping below has the least room.
// the default a fifth of the narrowest column went on space rather than on words.
tableCellPadding = 8.dp,
// What a column narrows to before the table starts scrolling sideways instead. It
// is the floor, not the width: a table with room to spare spreads across it.
//
// Down from the renderer's 160dp, and the number is a measurement rather than a
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
// makes even a three-column table -- the commonest shape there is -- scroll, while
// 136dp fits three across the phone this app is read on. Four and up still scroll,
// which is the right answer for genuinely too many columns: squeezing six columns
// into a phone would give every cell one word per line.
//
// Narrower would fit more, and stop being readable. This is the widest minimum
// that keeps three columns on screen, which is the trade the number is making.
// makes even a three-column table scroll, while 136dp fits three across the phone
// this app is read on. Four and up still scroll, which is the right answer for
// genuinely too many columns. This is the widest minimum that keeps three on
// screen.
tableCellWidth = 136.dp,
),
LocalMarkdownComponents provides
markdownComponents(
// The m3 renderer's own default, restored: supplying `components` at all replaces
// the whole set, and this is the only member of it the Material layer overrides.
// the whole set, and this is the only member the Material layer overrides.
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
// Everything that draws a run of text, so a link is a span rather than a node --
// see [LinkedText]. Setext headings take the same styles as `#` and `##`, which
// is the renderer's own pairing.
// see [LinkedText]. Setext headings take the same styles as `#` and `##`.
text = { LinkedText(it, it.typography.text) },
paragraph = { LinkedText(it, it.typography.paragraph) },
heading1 = { LinkedHeading(it, it.typography.h1) },
@@ -476,8 +437,8 @@ private fun MarkdownRoot(
heading6 = { LinkedHeading(it, it.typography.h6) },
setextHeading1 = { LinkedHeading(it, it.typography.h1) },
setextHeading2 = { LinkedHeading(it, it.typography.h2) },
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote --
// so they draw like the top-level ones the transcript cuts into items.
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so
// they draw like the top-level ones the transcript cuts into items.
orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
table = { LinkedTable(it.content, it.node, it.typography.table) },
@@ -496,14 +457,12 @@ private fun MarkdownRoot(
/**
* A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need.
*
* Each column has a floor ([markdownDimens]'s `tableCellWidth`), so the table is at least
* columns-times-floor wide; narrower than the room it has, it spreads to fill it, and wider, it
* scrolls sideways rather than squeezing. The renderer decided that with a `BoxWithConstraints`,
* which is a subcomposition; here it is one layout modifier, and the trick is where it sits.
* `fillMaxWidth` fixes the minimum width to the room available, the horizontal scroll passes that
* minimum through to its content while lifting the maximum to unbounded, and the modifier after it
* reads the minimum back as the room and sizes the rows to the larger of that and the floor. The
* scroll then has exactly the overflow to scroll, which is none when the table fits.
* Each column has a floor, so the table is at least columns-times-floor wide; narrower than the
* room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The
* renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one
* layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal
* scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after
* it reads the minimum back and sizes the rows to the larger of that and the floor.
*/
@Composable
private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
@@ -544,19 +503,15 @@ private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
* One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText].
*
* The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means
* most of a table is simply not readable: anything past about twenty characters ends in "..." with
* no way to see the rest, and an elided cell looks like a short one, so a table of measurements
* reads as a table of plausible shorter measurements. And they draw a link in a cell as its own
* layout node, the cost [LinkedText] exists to avoid.
* most of a table is simply not readable: an elided cell looks like a short one, so a table of
* measurements reads as a table of plausible shorter measurements. And they draw a link in a cell
* as its own layout node, the cost [LinkedText] exists to avoid.
*
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
* beside a one-line one centred the short one against the middle of the tall one and lost the line
* the reader was reading across. What the wrapping does *not* do is make a wide table fit;
* [LinkedTable] scrolls it instead, which is the right answer for too many columns -- wrapping a
* six-column table into the width of a phone would give every cell one word per line.
* beside a one-line one centred the short one against the middle of the tall one. What the wrapping
* does *not* do is make a wide table fit; [LinkedTable] scrolls it instead.
*
* The semantics are the renderer's: each cell is an item of the table's collection, and a header
* cell is a heading.
* The semantics are the renderer's: each cell is an item of the table's collection.
*/
@Composable
private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) {
@@ -592,19 +547,14 @@ private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowI
* Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much
* was written. Measured against a real Claude Code transcript on the emulator, one message took
* **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first
* tuned on -- so a page of history landing composed several rows that each stalled the frame they
* appeared in. That is the lag when a block loads.
* tuned on -- so a page of history landing composed several rows that each stalled the frame.
*
* Nothing here changes what a row does when it has no answer waiting: it parses inline, on the
* composing thread, because a row measured at nothing before it is measured at its real height
* collapses the transcript above it. The point is only that by the time the reader scrolls to a
* row, the answer is usually already made -- [warm] runs on a background thread as each page of
* history arrives, which is seconds before anybody reaches the rows it brought.
* Nothing here changes what a row does when it has no answer waiting: it parses inline, because a
* row measured at nothing before its real height collapses the transcript above it. The point is
* only that by the time the reader scrolls to a row, the answer is usually already made.
*
* A miss is not stored, and that is what bounds this: the map holds one entry per message a page
* warmed and nothing else, so a reply still streaming cannot fill it with hundreds of copies of
* itself on the way to being finished. It is dropped with the screen, and emptied by the stream
* reset that drops the rows it describes.
* warmed, so a reply still streaming cannot fill it with hundreds of copies of itself.
*/
@Stable
class ParsedReplies {
@@ -612,14 +562,13 @@ class ParsedReplies {
/**
* How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per
* fold, and walking the tree again each time is proportional to the message where a lookup is
* proportional to nothing.
* fold, and walking the tree again each time is proportional to the message.
*/
private val pieces = ConcurrentHashMap<String, List<Piece>>()
/**
* How each message divides into prose and memory notes, cached for the same reason as
* [piecesOf]: the regex scan behind [messageParts] is proportional to the message.
* How each message divides into prose and memory notes, cached for the same reason: the regex
* scan behind [messageParts] is proportional to the message.
*/
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
@@ -632,7 +581,7 @@ class ParsedReplies {
* much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator --
* and a lazy list drops the composition of a block that scrolls away, so a `remember` inside
* the fence paid that again every time the reader came back to it. Six times in one scroll,
* measured. [warm] fills this off the drawing thread before the row is reached.
* measured.
*/
private val highlights = ConcurrentHashMap<String, AnnotatedString>()
@@ -654,11 +603,10 @@ class ParsedReplies {
* Whether [warm] has made everything drawing [text] as pieces will look up.
*
* What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole
* message and the flatten runs on the composing thread -- so a reply not marked yet stays
* whole, drawing the parse it already has, until the screen has warmed it and re-flattens. An
* explicit mark rather than a peek into the parse cache, because a message with memory notes is
* warmed as its *parts*: nothing ever parses its full text, and inferring readiness from the
* cache left exactly that message unsplittable forever, re-warmed on every fold.
* message and the flatten runs on the composing thread, so a reply not marked yet stays whole
* until the screen has warmed it. An explicit mark rather than a peek into the parse cache,
* because a message with memory notes is warmed as its *parts*: nothing ever parses its full
* text, and inferring readiness from the cache left exactly that message unsplittable forever.
*/
fun splitReady(text: String): Boolean = text in ready
@@ -673,9 +621,8 @@ class ParsedReplies {
}
/**
* [code] coloured for [language] -- the answer made ahead, or one made now.
*
* The key carries the language, because the same code lexes differently under two of them.
* [code] coloured for [language] -- the answer made ahead, or one made now. The key carries the
* language, because the same code lexes differently under two of them.
*/
fun highlighted(code: String, language: Language?): AnnotatedString =
if (language == null) AnnotatedString(code)
@@ -691,9 +638,9 @@ class ParsedReplies {
*
* Suspending, and yielding between messages, because "off the composing thread" is not the same
* as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in
* a twelve second scroll, measured on a Pixel 9 Pro XL -- and on the default dispatcher that is
* every core busy, with the frame's own thread waiting for one. That showed up as 21ms of
* `waited` at the 90th percentile: the frame could not start, rather than taking too long.
* a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every
* core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th
* percentile.
*/
suspend fun warm(texts: List<String>) {
texts.forEach { text ->
@@ -702,9 +649,8 @@ class ParsedReplies {
DebugStats.timed("markdown warmed") { parseMarkdown(it) }
}
// The fences too, and here rather than in a pass of its own: they are found in the
// parse this just made, and lexing one is the same kind of cost as parsing the
// message it is in -- proportional to what was written, and charged to the frame
// that first draws it if nobody paid it earlier.
// parse this just made, and lexing one is the same kind of cost as parsing the message
// it is in.
fences(parse).forEach { (code, language) -> highlighted(code, language) }
}
}
@@ -719,12 +665,3 @@ class ParsedReplies {
ready.clear()
}
}
/**
* How much of the inline-code chip's fill is its own colour, the rest being whatever it sits on.
*
* High enough that the chip is still a clear step down from the page, low enough that a selection
* under it changes what the chip looks like. Both halves are the point: at 1.0 the chip was the
* only part of a selected sentence that did not look selected.
*/
private const val INLINE_CODE_ALPHA = 0.6f
@@ -9,7 +9,10 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.node.Ref
@@ -41,27 +44,22 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
*
* Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable,
* hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one of those
* annotations per link. Measured on the emulator against the same paragraphs with each link
* replaced by its label and address as plain words -- *more* text, the same gestures -- the linked
* version cost five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. On a
* Pixel 9 Pro XL that was the bump at the list of sources in a reply, and nowhere else in it.
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation
* per link. Measured on the emulator against the same paragraphs with each link replaced by its
* label and address as plain words -- *more* text, the same gestures -- the linked version cost
* five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time.
*
* Here a link is the link colour and underline, a string annotation carrying its address, and one
* tap detector for the whole text that asks the layout which character was under the finger. What
* that gives up is a link being its own accessibility node with a pressed state; the app's link
* style never defined a pressed style, so nothing visible changes.
*
* Every block the renderer dispatches through its component table comes here, which includes the
* paragraphs inside lists, quotes and alerts, and so does every table cell through
* [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
* resolves those against its definitions.
* Every block the renderer dispatches through its component table comes here, and so does every
* table cell. Reference-style links are the one kind still drawn the renderer's way.
*
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
* model put something, with no sign of what fell out. The link says what was there and where, and
* opens it. It also means no paragraph needs the renderer's own text composable, which existed to
* place inline images and charged every paragraph for the possibility.
* model put something. The link says what was there and where, and opens it.
*/
@Composable
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
@@ -71,8 +69,7 @@ fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
/**
* A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or
* `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it
* does not know, so handed the heading node itself it draws an empty line. Which is what this did
* for a week.
* does not know, so handed the heading node itself it draws an empty line.
*/
@Composable
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
@@ -96,21 +93,32 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour.
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
val chips = remember(text) { text.getStringAnnotations(CODE_CHIP, 0, text.length) }
val chipColor = LocalMarkdownColors.current.inlineCodeBackground
// Filled in by `onTextLayout`, which runs in the layout phase, so the draw of the same frame
// finds it set -- no state needed, and a relayout redraws the node anyway.
val chipFills = remember { Ref<List<Rect>>() }
val chipFill =
if (chips.isEmpty()) Modifier
else
Modifier.drawBehind {
chipFills.value?.forEach { drawRect(chipColor, it.topLeft, it.size) }
}
BasicText(
text = text,
modifier =
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the
// second one has to be answered from inside the text rather than left to the card.
modifier.pointerInput(text, onPlainTap) {
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second
// one has to be answered from inside the text rather than left to the card.
modifier.then(chipFill).pointerInput(text, onPlainTap) {
awaitEachGesture {
// Unconsumed is not required: something outside may already be tracking this
// press, and it is still the press that may land on a link.
awaitFirstDown(requireUnconsumed = false)
// A tap and nothing else. Null when the gesture became something somebody
// else's -- a scroll, or a press held past the long-press timeout, which is
// how a selection starts. The timeout is the load-bearing half: without it a
// press held for a second and released was still an up with nothing consumed,
// so holding a peer message to select from it shut the card instead.
// A tap and nothing else. Null when the gesture became somebody else's -- a
// scroll, or a press held past the long-press timeout, which is how a selection
// starts. The timeout is the load-bearing half: without it a press held for a
// second and released was still an up with nothing consumed, so holding a peer
// message to select from it shut the card instead.
val up =
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation()
@@ -130,7 +138,10 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
},
style = style,
color = { color },
onTextLayout = { layout.value = it },
onTextLayout = {
layout.value = it
chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) }
},
)
}
@@ -139,27 +150,24 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
* usually -- or null where a plain tap means nothing.
*
* A composition local because there is nowhere else to put it. The paragraphs of a message are
* composed by the renderer's own dispatch out of its component table, so nothing between a card and
* the text inside it is ours to pass a parameter through; the renderer already hands its colours,
* its typography and its components down the same way.
* composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours
* to pass a parameter through.
*
* It exists because a pointer-input node over the glyphs takes the tap and the card's own click
* handler never sees it. Measured on the emulator against an opened peer message: with a handler on
* the text -- consuming or not -- a tap on its words did nothing at all, and with the handler
* removed entirely the same tap shut the card. So a card whose body is markdown cannot be shut by
* pressing its words unless the words do the shutting, and "nothing happens when I press it" is
* indistinguishable from a card that has stopped working.
* handler never sees it. Measured against an opened peer message: with a handler on the text --
* consuming or not -- a tap on its words did nothing at all, and with the handler removed the same
* tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless
* the words do the shutting.
*
* Provided as a value that outlives a recomposition (see [rememberMarkdownTap]), since a fresh
* lambda per composition would invalidate every paragraph reading it.
* Provided as a value that outlives a recomposition, since a fresh lambda per composition would
* invalidate every paragraph reading it.
*/
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/**
* [onTap] as a stable value to provide for [LocalMarkdownTap].
*
* The identity stays put while the behaviour follows the latest [onTap], which is what keeps
* providing it from invalidating the text under it on every recomposition of the card.
* [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
* under it on every recomposition of the card.
*/
@Composable
fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit {
@@ -188,15 +196,80 @@ private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset):
private const val LINK_URL = "url"
/**
* The renderer's annotator settings with [appendPlainLink] answering for links. The annotator needs
* the settings to draw a link's label, and the settings hold the annotator, so the reference goes
* through a cell filled in once both exist.
* Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does,
* but with no background of its own -- if it is a code span; false leaves anything else to the
* renderer.
*
* The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's
* background is part of the text's own drawing, and the text node draws the selection first and the
* glyphs over it, so a chip painted as a span background covered the selection: selecting a
* sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the
* text is under both, which is where a fenced block's box already is.
*/
private fun appendCodeChip(
builder: AnnotatedString.Builder,
content: String,
node: ASTNode,
settings: AnnotatorSettings,
): Boolean {
if (node.type != MarkdownElementTypes.CODE_SPAN) return false
builder.pushStringAnnotation(CODE_CHIP, "")
builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified))
builder.append(' ')
// The backticks are the first and last children.
builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings)
builder.append(' ')
builder.pop()
builder.pop()
return true
}
private const val CODE_CHIP = "code"
/**
* One box per line of the text [start] until [end] covers, in the layout's own coordinates.
*
* Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every
* line but the last, so a chip whose code wrapped left a full-width empty box behind on the line
* above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space
* stops being drawn -- the same rule the selection rectangle obeys, so the two agree.
*
* A run's extent is taken from the boxes of its first and last characters, which is exact while a
* line reads in one direction; mixed directions inside a code span would draw one box across the
* whole run, and code spans are code.
*/
private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> {
val rects = mutableListOf<Rect>()
for (line in getLineForOffset(start)..getLineForOffset(end - 1)) {
val from = maxOf(start, getLineStart(line))
val to = minOf(end, getLineEnd(line, visibleEnd = true))
if (from >= to) continue
val head = getBoundingBox(from)
val tail = getBoundingBox(to - 1)
rects +=
Rect(
left = minOf(head.left, tail.left),
top = minOf(head.top, tail.top),
right = maxOf(head.right, tail.right),
bottom = maxOf(head.bottom, tail.bottom),
)
}
return rects
}
/**
* The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip]
* for inline code. The annotator needs the settings to draw a link's label, and the settings hold
* the annotator, so the reference goes through a cell filled in once both exist.
*/
@Composable
private fun plainLinkSettings(): AnnotatorSettings {
val cell = remember { Ref<AnnotatorSettings>() }
val annotator = remember {
markdownAnnotator { content, node -> appendPlainLink(this, content, node, cell.value!!) }
markdownAnnotator { content, node ->
appendPlainLink(this, content, node, cell.value!!) ||
appendCodeChip(this, content, node, cell.value!!)
}
}
return annotatorSettings(annotator = annotator).also { cell.value = it }
}
@@ -34,22 +34,18 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
*
* The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and
* is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a
* hundred short ones; and the list composes an item whole in the frame it scrolls into, so an item
* has to be bounded for the worst frame to be. Measured on a Pixel 9 Pro XL, the tallest row still
* being drawn was 36,982px, twenty-five screens in one message. A piece is a paragraph, a fence, a
* table, one bullet: bounded, so both costs are.
* hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on
* a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one
* message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are.
*
* Cut where the parser says the blocks are, which is the whole reason this is safe: a fence, a
* table and a nested list are each one node whatever is inside them, so nothing is ever split down
* the middle. A list is the one block that is not bounded -- a reply's list of sources can be forty
* items -- so it is cut once more, into its items, and a nested list stays inside the item that
* holds it.
* Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a
* nested list are each one node whatever is inside them. A list is the one block that is not
* bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its
* items.
*
* A piece is an *address* into the message's one parse ([block] indexes the root's children, [item]
* the list items of that child) rather than a substring of the message. Every piece of a message is
* drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and a
* reference definition at its foot still resolves the links above it -- the two costs of cutting a
* message into strings and parsing each on its own.
* A piece is an *address* into the message's one parse rather than a substring of it. Every piece
* is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and
* a reference definition at its foot still resolves the links above it.
*/
@Immutable
data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
@@ -59,8 +55,7 @@ data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
}
/**
* The pieces of [parse], in reading order. Blank nodes between blocks -- the parser keeps the
* newlines -- are not pieces.
* The pieces of [parse], in reading order. Blank nodes between blocks are not pieces.
*
* A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
* message that drew as nothing would be a hole in the transcript with no sign of what fell out.
@@ -90,17 +85,15 @@ fun gapBefore(previous: Piece?, piece: Piece): Dp =
val BLOCK_SPACING: Dp = 6.dp
/**
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which is what carries the
* theme, the components and the reference links to the renderer's element composables.
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme,
* the components and the reference links to the renderer's element composables.
*
* A whole block goes to the renderer's own dispatch with this app's component table, so a paragraph
* or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here
* through [MarkdownList]. Only the list item is drawn directly, because a list item is the one
* piece the renderer has no element for.
* A whole block goes to the renderer's own dispatch with this app's component table. Only the list
* item is drawn directly, because a list item is the one piece the renderer has no element for.
*
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply (see
* `LiveParse`): an item that is the first or last of its own parse but not of the list the reader
* sees keeps an inner item's padding, so nothing moves when the seam between segments does.
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an
* item that is the first or last of its own parse but not of the list the reader sees keeps an
* inner item's padding, so nothing moves when the seam between segments does.
*/
@Composable
fun MarkdownPiece(
@@ -112,8 +105,8 @@ fun MarkdownPiece(
listContinues: Boolean = false,
) {
if (parse !is State.Success) {
// The parser threw. Nothing else in the app has seen this happen; if it does, the words
// are still worth more than a blank.
// The parser threw. Nothing else in the app has seen this happen; if it does, the words are
// still worth more than a blank.
Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
return
}
@@ -144,8 +137,7 @@ fun MarkdownPiece(
/**
* A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a
* list inside a quote, and the nested lists an item holds. Top-level lists never come here; they
* are drawn an item at a time as pieces.
* list inside a quote, and the nested lists an item holds. Top-level lists never come here.
*/
@Composable
fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
@@ -170,9 +162,8 @@ fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier
* list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first
* and last items, since there is no list column to carry it.
*
* The marker is the renderer's bullet and number, and a checkbox for a task item. It is drawn here
* rather than by a handler because it is the thing a reader might one day want styled -- a
* different glyph per depth, a colour -- and this is the one place it is drawn.
* The marker is drawn here rather than by a handler because it is the thing a reader might one day
* want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn.
*/
@Composable
private fun MarkdownListItem(
@@ -231,8 +222,8 @@ private fun Marker(text: String, style: TextStyle) {
/**
* The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a
* browser draws, so a nested list is told from its parent by the glyph as well as by the indent.
* Checked on the emulator's system fonts, which is what makes them safe to rely on; a glyph the
* platform lacks draws as a box, and that check is the price of adding one here.
* Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check
* is the price of adding one here.
*/
private val BULLETS = listOf("", "", "")
@@ -0,0 +1,450 @@
package com.example.aiapp
/**
* Markdown read into the spans that carry a colour -- a ```markdown fence in a reply, and a `.md`
* file in the viewer.
*
* Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings:
* what a character means depends on where it sits. A `#` opens a heading at the start of a line and
* is an ordinary character three words in; a `*` opens emphasis only if something closes it on the
* same line. The token scanner cannot ask either question.
*
* Structure is read a line at a time and each line's prose left to right, so every decision is made
* inside one line -- except the two that are not. A fenced block is state carried forward, so an
* unclosed fence colours the rest of the text, which is what it looks like while somebody is
* writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot
* be anything else, and its header is the line before that -- the one place here that looks ahead.
*
* What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is
* one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by
* what came before. Colouring the wrong one as code is a mistake the reader cannot see.
*
* Like [scan], the spans come out ordered, non-overlapping and inside the text by construction.
*/
fun scanMarkdown(code: String): List<Span> = MarkdownScanner(code).run()
/** The characters an unordered list may be bulleted with. */
private const val BULLETS = "-*+"
/** The characters a thematic break, or a setext heading's underline, can be drawn with. */
private const val RULE_MARKERS = "-*_="
/** The characters that can open emphasis, strong emphasis or a strikethrough. */
private const val EMPHASIS = "*_~"
/** Characters that end a bare URL wherever they appear, and ones only trimmed off the end. */
private const val URL_STOPS = "<>\"'`|"
private const val URL_TRAILING = ".,:;!?"
private class MarkdownScanner(private val code: String) {
private val spans = ArrayList<Span>()
fun run(): List<Span> {
var at = 0
// The delimiter run that opened the fenced block we are inside, or null between them.
var fence: String? = null
// Whether the row above was part of a table, which is what makes this one a body row.
var table = false
while (at <= code.length) {
val end = lineEnd(at)
val open = fence
if (open != null) {
// The content and the closing line alike: a fence is one block of code, and its own
// delimiters belong to it the way a string's quotes belong to the string.
emit(at, end, Kind.STRING)
if (closesFence(at, end, open)) fence = null
} else {
val opened = opensFence(at, end)
fence = opened
if (opened != null) table = false else table = row(at, end, table)
}
if (end == code.length) break
at = end + 1
}
return spans
}
/** The end of the line beginning at [at]: the newline, or the end of the text. */
private fun lineEnd(at: Int): Int {
val newline = code.indexOf('\n', at)
return if (newline < 0) code.length else newline
}
/**
* One line that is not inside a fence, and whether the table it may be part of is still open.
*
* A table is recognised by its delimiter row, the only line of one that cannot be anything
* else. That row comes *after* the header it belongs to, so the header is found by looking one
* line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the
* document, which would mark the pipes in a shell command written in a paragraph.
*/
private fun row(start: Int, end: Int, table: Boolean): Boolean {
if (tableDelimiter(start, end)) {
emit(indented(start, end), end, Kind.MARK)
return true
}
val header = end < code.length && tableDelimiter(end + 1, lineEnd(end + 1))
if ((table || header) && hasPipe(start, end)) {
tableRow(start, end)
return true
}
structure(start, end)
return false
}
/** A line of nothing but pipes, dashes, alignment colons and space, with one of each needed. */
private fun tableDelimiter(start: Int, end: Int): Boolean {
var dashes = false
var pipes = false
for (at in indented(start, end) until end) {
when (code[at]) {
'-' -> dashes = true
'|' -> pipes = true
':',
' ',
'\t' -> {}
else -> return false
}
}
return dashes && pipes
}
private fun hasPipe(start: Int, end: Int): Boolean {
var at = start
while (at < end) {
if (code[at] == '\\') at += 2 else if (code[at] == '|') return true else at++
}
return false
}
/** A table row: the pipes are the structure, and what is between them is prose. */
private fun tableRow(start: Int, end: Int) {
var at = indented(start, end)
var cell = at
while (at < end) {
when (code[at]) {
'\\' -> at += 2
'|' -> {
inline(cell, at)
emit(at, at + 1, Kind.MARK)
at++
cell = at
}
else -> at++
}
}
inline(cell, end)
}
/**
* Spans, coalesced with the one before when they touch and agree. Worth doing here rather than
* leaving it to the caller: the line scanner emits per marker and per word, so a heading would
* otherwise arrive as a dozen abutting spans of one colour.
*/
private fun emit(start: Int, end: Int, kind: Kind) {
if (end <= start) return
val last = spans.lastOrNull()
if (last != null && last.kind == kind && last.end == start) {
spans[spans.size - 1] = Span(last.start, end, kind)
} else {
spans.add(Span(start, end, kind))
}
}
/** The first character of the line at or after [start] that is not indentation. */
private fun indented(start: Int, end: Int): Int {
var at = start
while (at < end && (code[at] == ' ' || code[at] == '\t')) at++
return at
}
/** The run of backticks or tildes that could open or close a fence on this line, or null. */
private fun fenceRun(start: Int, end: Int): IntRange? {
val at = indented(start, end)
if (at == end) return null
val marker = code[at]
if (marker != '`' && marker != '~') return null
var run = at
while (run < end && code[run] == marker) run++
return if (run - at >= 3) at until run else null
}
/** Draws an opening fence line and answers its delimiter, or null if this is not one. */
private fun opensFence(start: Int, end: Int): String? {
val run = fenceRun(start, end) ?: return null
emit(run.first, run.last + 1, Kind.STRING)
// The info word is what the fence is a fence *of*, which is metadata about the block rather
// than part of it.
emit(indented(run.last + 1, end), end, Kind.METADATA)
return code.substring(run.first, run.last + 1)
}
/**
* Whether this line closes a fence opened by [open]: the same character, at least as many of
* them, and nothing else on the line -- so a longer run closes a shorter one and a line of
* backticks with a word after it does not close anything.
*/
private fun closesFence(start: Int, end: Int, open: String): Boolean {
val run = fenceRun(start, end) ?: return false
if (code[run.first] != open[0] || run.last + 1 - run.first < open.length) return false
return indented(run.last + 1, end) == end
}
/** One ordinary line: what its opening characters make it, and then its prose. */
private fun structure(start: Int, end: Int) {
var at = indented(start, end)
// Quote markers come before everything else and can be several deep, and what follows one
// is an ordinary line again -- a heading inside a quote is still a heading.
while (at < end && code[at] == '>') {
at++
emit(at - 1, at, Kind.MARK)
at = indented(at, end)
}
if (at == end) return
if (heading(at, end) || thematicBreak(at, end)) return
inline(bullet(at, end), end)
}
/** `#` to `######` and a space. Without the space it is a word beginning with a hash. */
private fun heading(start: Int, end: Int): Boolean {
var at = start
while (at < end && code[at] == '#') at++
val depth = at - start
if (depth !in 1..6) return false
if (at < end && code[at] != ' ' && code[at] != '\t') return false
emit(start, end, Kind.KEYWORD)
return true
}
/**
* A line made of one repeated rule character and nothing else.
*
* `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a
* setext heading. The two are the same line to look at and mean the same thing to a reader, so
* they get one appearance rather than a lookback. One `=` is enough because a setext underline
* may be a single character; a break needs three, which keeps a `- ` bullet out of here.
*/
private fun thematicBreak(start: Int, end: Int): Boolean {
val marker = code[start]
if (marker !in RULE_MARKERS) return false
var seen = 0
for (at in start until end) {
val character = code[at]
if (character == marker) seen++ else if (!character.isWhitespace()) return false
}
if (seen < if (marker == '=') 1 else 3) return false
emit(start, end, Kind.MARK)
return true
}
/** Draws a list marker if the line opens with one, and answers where the item's text starts. */
private fun bullet(start: Int, end: Int): Int {
val marker = code[start]
if (marker in BULLETS && spaceOrEnd(start + 1, end)) {
emit(start, start + 1, Kind.MARK)
return indented(start + 1, end)
}
var digits = start
while (digits < end && code[digits].isDigit()) digits++
val delimiter = code.getOrNull(digits)
if (
digits > start && (delimiter == '.' || delimiter == ')') && spaceOrEnd(digits + 1, end)
) {
emit(start, digits + 1, Kind.MARK)
return indented(digits + 1, end)
}
return start
}
private fun spaceOrEnd(at: Int, end: Int) = at >= end || code[at] == ' ' || code[at] == '\t'
/**
* The inline forms, left to right.
*
* Every branch answers a position strictly after [start] of its call, so this terminates
* whether or not the form it was looking at turned out to be one.
*/
private fun inline(start: Int, end: Int) {
var at = start
while (at < end) {
val character = code[at]
at =
when {
// A backslash takes the character after it out of the running entirely, which
// is how `\*` stays an asterisk rather than opening emphasis.
character == '\\' -> at + 2
character == '`' -> codeSpan(at, end)
character == '[' -> link(at, at, end)
character == '!' && code.getOrNull(at + 1) == '[' -> link(at, at + 1, end)
character == '<' -> autolink(at, end)
character in EMPHASIS -> emphasis(at, end)
else -> url(at, end) ?: (at + 1)
}
}
}
/**
* `` `code` ``, closed by a run of exactly as many backticks as opened it. That count is what
* lets a span hold a backtick of its own, and why the search skips over a shorter or longer run
* rather than stopping at the first backtick.
*/
private fun codeSpan(start: Int, end: Int): Int {
var open = start
while (open < end && code[open] == '`') open++
val ticks = open - start
var at = open
while (at < end) {
if (code[at] != '`') {
at++
continue
}
var close = at
while (close < end && code[close] == '`') close++
if (close - at == ticks) {
emit(start, close, Kind.STRING)
return close
}
at = close
}
// Nothing closes it on this line, so those were ordinary backticks.
return open
}
/**
* `[text](destination)`, and the same with a leading `!` for an image.
*
* The text is drawn as prose -- it is what the reader reads -- so only the brackets around it
* are marked, and the destination is metadata. A `[text]` with no destination after it is left
* plain, because that is what a reference link and a bracketed aside look like.
*/
private fun link(start: Int, bracket: Int, end: Int): Int {
var depth = 0
var close = bracket
while (close < end) {
when (code[close]) {
'\\' -> close++
'[' -> depth++
']' -> {
depth--
if (depth == 0) break
}
}
close++
}
if (close >= end) return start + 1
val destination = close + 1
if (code.getOrNull(destination) != '(') return start + 1
val paren = code.indexOf(')', destination)
if (paren < 0 || paren >= end) return start + 1
emit(start, bracket + 1, Kind.MARK)
inline(bracket + 1, close)
emit(close, destination, Kind.MARK)
emit(destination, paren + 1, Kind.METADATA)
return paren + 1
}
/**
* `<https://example.com>` and `<name@example.com>`, drawn as the destination they are.
*
* The angle brackets have to hold no whitespace and something that makes an address of it -- a
* scheme's colon or an at sign -- which is what keeps an HTML tag out.
*/
private fun autolink(start: Int, end: Int): Int {
var at = start + 1
var addressed = false
while (at < end) {
val character = code[at]
if (character.isWhitespace() || character == '<') return start + 1
if (character == '>') {
if (!addressed) return start + 1
emit(start, at + 1, Kind.METADATA)
return at + 1
}
if (character == ':' || character == '@') addressed = true
at++
}
return start + 1
}
/**
* A bare `scheme://…` written in prose, or null if one does not start here.
*
* A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry.
*
* Where it ends is the part worth stating: the sentence's punctuation is not the address, so a
* trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the
* URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a
* table cell must not swallow the cell's edge.
*/
private fun url(start: Int, end: Int): Int? {
if (start > 0 && isWord(code[start - 1])) return null
var scheme = start
while (scheme < end && code[scheme].isLetter()) scheme++
if (scheme == start || !code.startsWith("://", scheme)) return null
val body = scheme + 3
var at = body
var openers = 0
var closers = 0
while (at < end && !code[at].isWhitespace() && code[at] !in URL_STOPS) {
if (code[at] == '(') openers++ else if (code[at] == ')') closers++
at++
}
while (at > body) {
val last = code[at - 1]
if (last in URL_TRAILING) at--
else if (last == ')' && closers > openers) {
closers--
at--
} else break
}
if (at == body) return null
emit(start, at, Kind.METADATA)
return at
}
/**
* `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all -- which is how the
* token scanner draws a string: the quotes are part of the thing.
*
* The two guards keep this off code that happens to be in a paragraph: the opener must be
* followed by something to emphasise and the closer preceded by something emphasised, so `a * b
* * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start
* or end inside a word, or every `snake_case_name` would be half emphasised.
*/
private fun emphasis(start: Int, end: Int): Int {
val marker = code[start]
var open = start
while (open < end && code[open] == marker) open++
val length = open - start
if (marker == '~' && length != 2) return open
if (length > 3) return open
if (open == end || code[open].isWhitespace()) return open
if (marker == '_' && start > 0 && isWord(code[start - 1])) return open
var at = open
while (at < end) {
if (code[at] == '\\') {
at += 2
continue
}
if (code[at] != marker) {
at++
continue
}
var close = at
while (close < end && code[close] == marker) close++
val finish = at + length
if (
close - at >= length &&
!code[at - 1].isWhitespace() &&
!(marker == '_' && finish < end && isWord(code[finish]))
) {
emit(start, finish, Kind.LITERAL)
return finish
}
at = close
}
return open
}
}
private fun isWord(character: Char) = character.isLetterOrDigit() || character == '_'
@@ -25,12 +25,11 @@ import androidx.compose.ui.unit.dp
* Claude Code marks a sentence that came from its stored memory by wrapping it in `<cc-memory
* filenames="...">`. Markdown has nothing to say about that, so it arrived on screen as literal
* angle brackets in the middle of a sentence -- which reads as the model having emitted broken
* HTML. It is really the opposite: a claim about where something came from, which is worth showing,
* because "I was told this before" and "I worked this out just now" are different things and the
* reader cannot otherwise tell them apart.
* HTML. It is really the opposite: a claim about where something came from, and "I was told this
* before" and "I worked this out just now" are different things the reader cannot otherwise tell
* apart.
*
* A tag that has not finished arriving is left alone. Streaming means the closing tag may be
* seconds away, and a half-written marker is not a marker yet.
* A tag that has not finished arriving is left alone: a half-written marker is not a marker yet.
*/
@Composable
fun AssistantMessage(
@@ -66,12 +65,10 @@ fun AssistantMessage(
* A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed
* prose part made while looking for them -- inspecting a message must not change it. That belongs
* here rather than at the places that need the answer, because [warm] has to name the same strings
* the rows draw: a string warmed under a key no row ever looks up is a miss that nothing reports,
* and the row pays the parse in the frame it appears, which is the cost being removed.
* the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports.
*
* Public because [transcriptUnits] flattens settled replies into the same parts; go through
* [ParsedReplies.partsOf] on any path that runs per fold or per page, so the scan happens once per
* message.
* [ParsedReplies.partsOf] on any path that runs per fold or per page.
*/
fun messageParts(text: String): List<MessagePart> {
val parts = splitMemoryNotes(text)
@@ -83,16 +80,14 @@ fun messageParts(text: String): List<MessagePart> {
*
* Closed by default, like a tool call and a peer message and for the same reason: it is not part of
* what was said to the reader, it is a note about where a claim came from. Left open it breaks the
* reply in half around a card, which reads as the answer having stopped and restarted -- and these
* arrive several to a message.
* reply in half around a card, and these arrive several to a message.
*
* What stays visible is which file it came from, because that is the whole of what the note claims
* and it is the part a reader scanning for "why does it think that" is looking for.
* and the part a reader scanning for "why does it think that" is looking for.
*
* Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to
* still be open on the way back, and a card that remembered for itself would forget the moment the
* list stopped composing it. The text is a good enough name -- it does not change once the closing
* tag has arrived, so a note stays open across the moment its reply settles.
* list stopped composing it.
*/
@Composable
fun MemoryNote(
@@ -148,10 +143,8 @@ private val MEMORY_NOTE =
Regex("""<cc-memory\s+filenames="([^"]*)"\s*>(.*?)</cc-memory>""", RegexOption.DOT_MATCHES_ALL)
/**
* Splits [text] into prose and memory notes, in order.
*
* Always returns at least one part, so a message with no notes in it is one piece of prose and
* costs nothing extra to draw.
* Splits [text] into prose and memory notes, in order. Always returns at least one part, so a
* message with no notes is one piece of prose and costs nothing extra to draw.
*/
fun splitMemoryNotes(text: String): List<MessagePart> {
val parts = mutableListOf<MessagePart>()
@@ -5,26 +5,23 @@ package com.example.aiapp
*
* One constant rather than a literal in each place, because the two have to agree: a picker whose
* options cannot say every state its button can display is one you can leave and not get back to.
* It is also the Claude CLI's own word for "whatever is configured", so choosing it is a request
* the session can act on rather than a name this app made up.
* It is also the Claude CLI's own word for "whatever is configured".
*/
const val DEFAULT_MODEL = "default"
/**
* A model's name as a person reads it.
*
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to
* `claude-haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session
* using" and far too long for a button in a row that also has to hold Stop and Send.
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude-
* haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using"
* and far too long for a button in a row that also holds Stop and Send.
*
* So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which
* is the same on every model this app can show, and the release date, which distinguishes builds of
* one model rather than one model from another. What is left is the part somebody chose --
* `haiku-4-5` -- and anything that does not look like that is returned untouched, since a name this
* does not recognise is a name it has no business editing.
* one model rather than one model from another. Anything that does not look like that is returned
* untouched.
*
* A display decision, not a correction: the full name is what the session reports and what a reader
* is shown when there is room for it.
* A display decision, not a correction: the full name is what the session reports.
*/
fun modelLabel(model: String?): String {
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
@@ -57,12 +57,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
}
}
// Polled rather than pushed: a download belongs to the machine, not to
// any session, so it has no event stream of its own. Slow enough not
// to matter, frequent enough that a bar moves.
// Keyed on the token as well, so the header's Refresh restarts the loop with a read now
// rather than leaving the reader watching for up to a second and a half to see whether
// anything happened.
// Polled rather than pushed: a download belongs to the machine, not to any session, so it has
// no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the
// loop with a read now rather than leaving the reader watching for a second and a half.
LaunchedEffect(reloadToken) {
while (true) {
reload()
@@ -186,11 +183,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
}
}
}
// Inside the expanded repository's own item
// rather than as a section after the list:
// drawn after every card, a repository's files
// read as belonging to whichever card happened
// to be last.
// Inside the expanded repository's own item rather than as a section
// after the list: drawn after every card, a repository's files read as
// belonging to whichever card happened to be last.
if (open) {
when (val files = repoFiles) {
null -> {}
@@ -257,15 +252,15 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. The server
// sends no total when it was never told one, and a bar drawn
// from a guess is worse than one that admits it is counting.
// A determinate bar only when the size is known. The server sends no total when it was
// never told one, and a bar drawn from a guess is worse than one that admits it is
// counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say
// the opposite of what is happening.
// nearing success, and colouring it like a limit being approached would say the
// opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
@@ -327,8 +322,8 @@ private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at
// the end is what tells two entries apart.
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
@@ -356,11 +351,9 @@ private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether
// this one is absent, already here, or on its way. Offering
// "Download" for a file that is downloading would be a button that
// does nothing anyone can see -- the server joins the running
// download rather than starting a second.
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
@@ -26,26 +26,21 @@ import androidx.compose.ui.unit.sp
* set and kept in step by hand.
*
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
* grounds that a system font may not have the glyph and whoever gets the empty box instead is never
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly
* right: the answer is not to avoid glyphs but to ship them. The font here is
* `app/build-icon-font.sh`'s output -- eleven glyphs, 2.1 KB, subset out of the 3 MB symbols font
* and committed -- so the codepoints below are resolved by an asset in the APK and cannot come back
* as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script
* did not subset is a glyph that silently isn't there.
* grounds that a system font may not have the glyph. That objection is about *relying* on a system
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
* is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
* the script did not subset is a glyph that silently isn't there.
*
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
* That is what makes two icons the same size without either of them being given a size: the
* proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side
* by side came out visibly different widths, and matching them at the call site would have meant
* one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost.
* That is what makes two icons the same size without either being given a size: the proportional
* face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came
* out visibly different widths. [GLYPH_SIZE] carries the cost.
*
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
* Material Design codepoints. Those two must not drift: an icon that means "settings" in one app
* and something else in the other is the failure this is worth preventing. The script is copied
* rather than shared because most of what looks like duplication is the `GLYPHS` list, which has to
* differ -- the point of subsetting is to ship only the codepoints one app draws. All Material
* Design bar one, so they read as one family; the exception is noted where it is declared.
* Material Design codepoints. Those two must not drift. The script is copied rather than shared
* because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the
* point of subsetting is to ship only the codepoints one app draws.
*/
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
@@ -74,8 +69,7 @@ val STOP_GLYPH = glyph(0xF04DB)
*
* The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what
* pressing it now would do to the process, and the three marks are the three answers. An interrupt
* ends a turn and nothing else -- the CLI is still there and still holds the conversation -- which
* is a pause, not a stop, and drawing it as a square said otherwise.
* ends a turn and nothing else, which is a pause, not a stop.
*/
val PAUSE_GLYPH = glyph(0xF03E4)
@@ -86,8 +80,8 @@ val PLAY_GLYPH = glyph(0xF040A)
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
*
* The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than
* starting one, and the two buttons have to be told apart at a glance -- one glyph doing both jobs
* while looking identical would promise something immediate and do something that waits.
* starting one, and one glyph doing both jobs would promise something immediate and do something
* that waits.
*/
val QUEUE_GLYPH = glyph(0xF1163)
@@ -104,8 +98,7 @@ val BELL_GLYPH = glyph(0xF009A)
* `fa-line_chart` -- how much of the account's rate limits is gone.
*
* Font Awesome's rather than Material's, which is the one break in the family above: it was asked
* for by name, and Material's chart glyphs are a bare line where this one has its axes, which is
* what makes it read as a measurement rather than as a trend.
* for by name, and Material's chart glyphs are a bare line where this one has its axes.
*/
val USAGE_GLYPH = glyph(0xF201)
@@ -117,14 +110,39 @@ val USAGE_GLYPH = glyph(0xF201)
*/
val SPEED_GLYPH = glyph(0xF04C5)
/**
* `md-folder` -- the files on the machine this session runs on.
*
* The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and
* the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is
* what makes the button say where it leads.
*/
val FOLDER_GLYPH = glyph(0xF024B)
/** `md-file_outline` -- one file, in a listing beside the directories. */
val FILE_GLYPH = glyph(0xF0224)
/** `md-plus` -- make something here. dev-updater's codepoint as well. */
val PLUS_GLYPH = glyph(0xF0415)
/** `md-pencil` -- change what this file says, rather than only reading it. */
val EDIT_GLYPH = glyph(0xF03EB)
/**
* `md-content_save` -- write the edits back to the machine.
*
* The floppy disk, which is what save has meant for longer than most of the people reading it have
* been alive and is still the only mark anybody recognises for it.
*/
val SAVE_GLYPH = glyph(0xF0193)
/**
* The size an icon draws at beside a line of text.
*
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
* most 0.83 em of its point size and most filled a good deal less, so the number was standing in
* for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and
* keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked
* for. This is what the largest of them already drew at.
* most 0.83 em of its point size, so the number was standing in for the headroom above the tallest
* one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every
* icon in the app up by a fifth.
*/
private val GLYPH_SIZE = 17.sp
@@ -138,16 +156,14 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
*
* The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to
* the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of
* its own, and a mark cannot end up further from the button beside it than from the edge of the
* screen. That is what it was: the box was the size of the mark (28dp) and the separation was
* its own. That is what it was: the box was the size of the mark (28dp) and the separation was
* bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the
* edge, so a pair that acts on one screen read as two unrelated marks with one falling off it.
* edge.
*
* 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has
* to find. It is what the pressed-state ripple draws, too: at 28dp that circle was inscribed in the
* mark's own corners, and beside a title it arrived at the first letter. And it is taller than any
* header's text, which is what lets the button fill a header row rather than sit in the middle of
* one -- the rows add no vertical padding of their own for the same reason they add no gap.
* to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's
* own corners and beside a title it arrived at the first letter. And it is taller than any header's
* text, which is what lets the button fill a header row rather than sit in the middle of one.
*/
private val GLYPH_BUTTON_SIZE = 48.dp
@@ -155,11 +171,9 @@ private val GLYPH_BUTTON_SIZE = 48.dp
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
* a back arrow.
*
* Two glyph buttons need nothing between them: each brings its own ring and the two add up, which
* is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for.
* Without it the pressed-state circle, which fills the whole square, arrives at the first letter of
* the title -- and the gap a reader sees between the mark and that title is then half the one
* between the two marks at the other end of the same row.
* Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text
* brings none, so the second ring has to be asked for -- without it the pressed-state circle
* arrives at the first letter of the title.
*/
val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
@@ -168,8 +182,7 @@ val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
*
* Its own composable so that every icon button in the app is one size and one colour without each
* caller saying so, and so the [label] none of them displays is still there for a screen reader --
* which is all assistive technology has to go on, and also the answer to "what was that button for"
* six months from now.
* which is also the answer to "what was that button for" six months from now.
*
* [enabled] is passed through rather than left to callers hiding the button: a control that comes
* and goes makes its own absence the signal, and absence cannot say whether there was nothing to do
@@ -193,9 +206,8 @@ fun GlyphButton(
* The same square, around a mark that is not a glyph.
*
* A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the
* size, spacing and touch target every other icon button on this app's headers already is -- so
* this is [GlyphButton] with the mark left to the caller rather than a second set of measurements
* beside it. The caller still owes it a [label]: nothing here draws a word.
* size, spacing and touch target every other icon button already is. The caller still owes it a
* [label]: nothing here draws a word.
*/
@Composable
fun MarkButton(
@@ -218,9 +230,7 @@ fun MarkButton(
* The square a glyph button occupies, with a spinner in it instead of a mark.
*
* For a button whose work is under way. It takes the button's whole box rather than the mark's, so
* swapping one for the other leaves everything in the row exactly where it was -- a control that
* changed the width of its header while it worked would move its neighbours at the moment somebody
* was pressing them.
* swapping one for the other leaves everything in the row exactly where it was.
*/
@Composable
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
@@ -246,10 +256,9 @@ fun Glyph(
size: TextUnit = GLYPH_SIZE,
) {
// Line height of the point size, which for this font is the square the glyph draws in: its
// ascent and descent add up to exactly one em, and every glyph in the Mono face fills that em.
// Left to the inherited body style the line box was 24sp tall around a 17sp-wide mark, so a
// glyph took a seventh more vertical space than horizontal wherever one is drawn without a box
// around it -- and where there is a box, that leading is what its padding is measured through.
// ascent and descent add up to exactly one em. Left to the inherited body style the line box
// was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than
// horizontal.
Text(
glyph,
fontFamily = NerdIcons,
@@ -34,12 +34,11 @@ import org.json.JSONObject
* gets a push from Google's servers, which would mean this backend talking to Google about
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
*
* The cost Android charges for it is a notification of its own that cannot be dismissed. That is
* made as quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no
* sound, shows no status-bar icon, and sits at the bottom of the shade -- the same arrangement
* Syncthing's "hide the persistent notification" option produces. It is not hidden outright,
* because it cannot be and because it should not be: it is the honest indicator that something is
* holding a connection open.
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
* cannot be and because it should not be: it is the honest indicator that something is holding a
* connection open.
*/
class NotificationService : Service() {
@Volatile private var stream: HttpURLConnection? = null
@@ -50,18 +49,18 @@ class NotificationService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val settings = loadServerSettings(this)
if (settings == null) {
// Nothing to connect to. Stopping rather than idling: a service
// holding no connection still costs the ongoing notification,
// which would then be announcing work that is not happening.
// Nothing to connect to. Stopping rather than idling: a service holding no connection
// still costs the ongoing notification, which would be announcing work that is not
// happening.
stopSelf()
return START_NOT_STICKY
}
// Through ServiceCompat so the type is stated once and ignored on
// the versions that predate types, rather than branching here.
// Through ServiceCompat so the type is stated once and ignored on the versions that predate
// types, rather than branching here.
ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType())
thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) }
// Restarted if Android kills it, which is the whole point: the
// window this covers is exactly the one where nobody is watching.
// Restarted if Android kills it, which is the whole point: the window this covers is
// exactly the one where nobody is watching.
return START_STICKY
}
@@ -73,11 +72,10 @@ class NotificationService : Service() {
/**
* Follows the backend's notification stream, reconnecting until stopped.
*
* A dropped connection is the ordinary case here rather than an error -- a phone changes
* networks, the tunnel comes and goes, the backend restarts -- so it retries quietly and
* forever. Nothing is shown when it cannot connect: a notification saying "I could not tell you
* whether anything happened" on a phone in somebody's pocket is noise about a condition they
* cannot act on, and the session list already says what is waiting when they next look.
* A dropped connection is the ordinary case here rather than an error, so it retries quietly
* and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell
* you whether anything happened" is noise about a condition nobody can act on, and the session
* list already says what is waiting when they next look.
*/
private fun follow(settings: ServerSettings) {
while (!stopping) {
@@ -102,8 +100,8 @@ class NotificationService : Service() {
try {
connection.applyPinnedTls()
connection.connectTimeout = CONNECT_TIMEOUT_MS
// No read timeout, for the reason EventStream gives: between
// notifications there is nothing to read, possibly for hours.
// No read timeout, for the reason EventStream gives: between notifications there is
// nothing to read, possibly for hours.
connection.readTimeout = 0
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
connection.setRequestProperty("Accept", "text/event-stream")
@@ -134,28 +132,23 @@ class NotificationService : Service() {
*
* Keyed by session id rather than accumulating: two sessions wanting attention are two things
* to know about, but one session that finished and then asked a question is one thing -- the
* question. A stack of stale rows for the same conversation is how a notification drawer
* becomes something to clear rather than read.
* question. A stack of stale rows is how a drawer becomes something to clear rather than read.
*/
private fun show(notification: SessionNotification) {
// Nothing to tell somebody about the session they are reading. The transcript in front of
// them is already saying it, and a sound over the top of it would be this app announcing
// what the screen is showing.
// them is already saying it.
if (isOnScreen(notification.sessionId)) return
// The app is up: it says this itself, as a banner over whatever screen they are on. See
// [forTheScreen]. Never both -- one thing happened, and a drawer filling up behind an
// app that already showed you each one is a drawer nobody reads.
// The app is up: it says this itself, as a banner over whatever screen they are on. Never
// both -- one thing happened, and a drawer filling up behind an app that already showed you
// each one is a drawer nobody reads.
if (handOver(notification)) return
val manager = NotificationManagerCompat.from(this)
// Two different noes, and both are answers rather than faults: the runtime permission
// refused, and notifications switched off for the app in Android's own settings. Neither
// is reported anywhere -- the person said no, and saying it back to them through the
// channel they closed is not available anyway.
// refused, and notifications switched off for the app in Android's own settings.
//
// The permission only exists from Android 13. Asking an older version about it gets
// "denied" for a name it does not know, which read as the person having said no -- so
// every notification on Android 12 and below was silently dropped. Before 13 the
// switch in Android's own settings, checked below, is the whole of the answer.
// "denied" for a name it does not know, which read as the person having said no -- so every
// notification on Android 12 and below was silently dropped.
val allowed =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
@@ -187,9 +180,8 @@ class NotificationService : Service() {
* The type Android 14+ requires a foreground service to declare, and nothing before it.
*
* Named behind a version check rather than passed as a constant: the value is inlined at
* compile time and would be handed to platforms that have no concept of it, which is exactly
* the case lint's InlinedApi exists to catch. Zero is what ServiceCompat wants where types do
* not apply.
* compile time and would be handed to platforms that have no concept of it, which is what
* lint's InlinedApi exists to catch.
*/
private fun foregroundType(): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
@@ -226,11 +218,10 @@ class NotificationService : Service() {
/**
* Two channels, because they are two different things to be told.
*
* The alerts are what somebody turned this on for, so they get the default importance and
* whatever sound and heads-up display the person has chosen for the app. The ongoing one is
* the platform's tax for staying connected, so it takes the lowest importance that exists.
* Both are created before the service starts, since posting to a channel that does not
* exist is silently dropped.
* The alerts are what somebody turned this on for, so they get the default importance. The
* ongoing one is the platform's tax for staying connected, so it takes the lowest
* importance that exists. Both are created before the service starts, since posting to a
* channel that does not exist is silently dropped.
*/
private fun createChannels(context: Context) {
val manager = NotificationManagerCompat.from(context)
@@ -256,12 +247,10 @@ class NotificationService : Service() {
* The session somebody is looking at, or null when no screen is showing one.
*
* Process-wide state, which the rest of this app does without: Android constructs the
* service and the composition draws the screen, so the two have no common owner a value
* could be passed through. [showing] and [stoppedShowing] are the pair, both called from
* the one composable that shows a session. Clearing names the session rather than setting
* null outright, because moving from one session to another composes the new screen before
* the old one's coroutine is cancelled -- an unconditional clear would then throw away the
* new screen's claim and start notifying about what is on it.
* service and the composition draws the screen, so the two have no common owner. Clearing
* names the session rather than setting null outright, because moving from one session to
* another composes the new screen before the old one's coroutine is cancelled -- an
* unconditional clear would throw away the new screen's claim.
*/
@Volatile private var onScreen: String? = null
@@ -271,10 +260,9 @@ class NotificationService : Service() {
* The way a notification reaches the app instead of Android's drawer.
*
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
* [SessionAlerts] collects this exactly while it is on screen, so there is nothing that
* could be left saying the app is up after it has gone. `tryEmit` neither suspends nor
* blocks the thread reading the stream, and the buffer is there so a handful of sessions
* finishing together all land rather than the last one winning.
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
* nor blocks the thread reading the stream, and the buffer is there so a handful of
* sessions finishing together all land rather than the last one winning.
*/
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
@@ -287,9 +275,8 @@ class NotificationService : Service() {
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
fun showing(context: Context, sessionId: String) {
onScreen = sessionId
// Whatever was posted about it before is about to be read, so it has nothing left
// to say -- and a row in the drawer for the conversation on screen is the same
// duplication this whole rule is about.
// Whatever was posted about it before is about to be read, so it has nothing left to
// say.
NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID)
}
@@ -311,8 +298,8 @@ class NotificationService : Service() {
* The intent that opens one session, and the id it carries back out.
*
* The two halves are written together so neither can be changed without the other, and the scheme
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look at
* when an intent arrives rather than two.
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look
* at.
*
* The id rides in the intent's **data** rather than in an extra, which is not a style choice:
* PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring
@@ -345,9 +332,8 @@ data class SessionNotification(
* What a notification asks of the reader, in the words they see.
*
* What they have to do, not what the session did: "awaitingInput" is the wire's word and says
* nothing to somebody reading a lock screen. One function because the same fact is now shown in two
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The
* banner colours the line as well, which is its own decision and stays with the drawing.
* nothing to somebody reading a lock screen. One function because the same fact is shown in two
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift.
*/
fun attentionLine(kind: String): String =
when (kind) {
@@ -31,13 +31,12 @@ import androidx.compose.ui.unit.dp
*
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
* transcript that puts it in their voice is making a claim about who asked for the work that
* follows -- which is exactly the question a peer message is usually the answer to.
* follows.
*
* Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block,
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for the measurements that
* bought; what matters here is that the pieces have to add up to the card that was there before, so
* the fill, the corner radius and the padding all live in [peerSurface] rather than being written
* out at each piece.
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought;
* what matters here is that the pieces have to add up to the card that was there before, so the
* fill, the corner radius and the padding all live in [peerSurface].
*/
@Composable
fun PeerHeadRow(
@@ -91,9 +90,9 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
// Without this the card closes everywhere except on the text, which is most of it.
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
// The gap the card's own column used to provide between its heading and its prose,
// and between one block and the next -- inside the piece, so the card's fill runs
// through it.
// The gap the card's own column used to provide between its heading and its prose, and
// between one block and the next -- inside the piece, so the card's fill runs through
// it.
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
}
}
@@ -102,16 +101,14 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl
/**
* One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it.
*
* A filled Material card is elevation zero ([CardDefaults] takes it from `FilledCardTokens`, which
* is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason a
* card can be cut up at all. Each piece paints the caller's container colour the way a
* [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message,
* so the pieces abut into one continuous card. Shared by the two rows that are cut this way -- an
* opened peer message and a long user message -- because two copies of the corner logic is how one
* of them grows a seam.
* A filled Material card is elevation zero, so there is no shadow that a seam would show through --
* which is the whole reason a card can be cut up at all. Each piece paints the caller's container
* colour and rounds only the corners at the ends of the message, so the pieces abut into one
* continuous card. Shared by the two rows cut this way -- an opened peer message and a long user
* message -- because two copies of the corner logic is how one of them grows a seam.
*
* The padding is the other half of it: 12dp all round was the card's own, so the top piece keeps
* the top of it, the bottom piece the bottom, and the middle pieces neither.
* The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top
* of it, the bottom piece the bottom, and the middle pieces neither.
*/
@Composable
fun Modifier.cardPiece(
@@ -34,13 +34,10 @@ import androidx.compose.ui.unit.sp
* What is about to be sent, directly above the box it will be sent from.
*
* The count on the "+" button was the whole of what said an image was attached, so the only way to
* find out *which* image was to send it. A control belongs with the thing it acts on, and what
* these are attached to is the message being typed -- which is why they sit here rather than
* anywhere else on the screen.
* find out *which* image was to send it. A control belongs with the thing it acts on.
*
* Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is
* in it, so four attachments look like four of the same thing rather than four smaller ones. A file
* is a tile of the same height carrying its name, since a name is all there is to show of it.
* in it, so four attachments look like four of the same thing rather than four smaller ones.
*/
@Composable
fun PendingAttachments(
@@ -67,8 +64,7 @@ fun PendingAttachments(
*
* Removal is here because there is nowhere else it could be: an image picked by mistake could
* otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip -- and
* the label is what says so, since nothing about the picture does.
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip.
*/
@Composable
private fun PendingThumbnail(
@@ -84,8 +80,7 @@ private fun PendingThumbnail(
.clip(shape)
// An outline as well as a fill. Most of what gets attached here is a screenshot of a
// dark app, and cropped to a square its middle is often near-black -- against this
// background the tile then had no edge at all, and the only thing saying an image was
// attached was the cross drawn on top of nothing.
// background the tile then had no edge at all.
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
// Behind the picture as well as under a missing one, so the tile is a tile before
// anything has arrived to fill it.
@@ -105,9 +100,9 @@ private fun PendingThumbnail(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// A spinner, as the transcript's images have: one appearance for "a picture
// is on its way", learned once. An ellipsis had to be read as a spinner that
// was not moving.
// A spinner, as the transcript's images have: one appearance for "a picture is
// on its way", learned once. An ellipsis had to be read as a spinner not
// moving.
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
else ->
@@ -118,14 +113,12 @@ private fun PendingThumbnail(
modifier = Modifier.size(THUMBNAIL),
)
}
// The whole square removes it, and this only says so. A cross small enough to sit in
// the corner of a 64dp thumbnail is smaller than a fingertip, so making it the target
// would be a control drawn at a size nobody can hit.
// The whole square removes it, and this only says so. A cross small enough to sit in the
// corner of a 64dp thumbnail is smaller than a fingertip.
//
// The disc is sized here and the mark centred inside it, rather than the glyph being
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box
// to the corner hung the visible mark over the edge and put its backing somewhere the
// eye reads as a second, misplaced square.
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to
// the corner hung the visible mark over the edge.
Box(
Modifier.align(Alignment.TopEnd)
.padding(2.dp)
@@ -3,15 +3,13 @@ package com.example.aiapp
import com.example.wgapplink.PinnedTls
import java.net.HttpURLConnection
// PINNED_CA_PEM is generated at build time from the CA on the machine doing
// the build -- see the generatePinnedCert task in build.gradle.kts. It is
// deliberately not a checked-in constant: the private key that signs against
// it must never be anywhere this repo is, and an APK should pin whatever CA
// the backend it was built for actually serves.
// PINNED_CA_PEM is generated at build time from the CA on the machine doing the build -- see the
// generatePinnedCert task in build.gradle.kts. It is deliberately not a checked-in constant: the
// private key that signs against it must never be anywhere this repo is, and an APK should pin
// whatever CA the backend it was built for actually serves.
//
// The pinning itself lives in wg-app-link, since dev-updater needs exactly
// the same thing. What stays here is the one product-specific fact -- which
// certificate this app pins.
// The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What
// stays here is which certificate this app pins.
private val pinned = PinnedTls(PINNED_CA_PEM)
/** Every request this app makes goes through this -- there is no unpinned path. */
@@ -16,20 +16,17 @@ import androidx.compose.ui.unit.dp
*
* A composable rather than a modifier repeated at each site, because the inset is part of it --
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
* copies of "clip, fill, pad" drift apart the first time one of them is adjusted.
* copies of "clip, fill, pad" drift apart the first time one is adjusted.
*
* The colour is [rawSurface], which is also what a code block inside a reply is given; that is the
* point of having one name for it. Markdown's blocks are painted by the renderer rather than by
* this, since it draws its own, but they are the same colour on purpose.
* The colour is [rawSurface], which is also what a code block inside a reply is given.
*/
@Composable
fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier
.fillMaxWidth()
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a
// rounded rectangle drawn at the same radius as the rounded rectangle behind it reads
// as a misprint rather than as nesting.
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded
// rectangle drawn at the same radius as the one behind it reads as a misprint.
.clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface)
.padding(horizontal = 8.dp, vertical = 6.dp),
@@ -4,17 +4,16 @@ import java.time.Duration
import java.time.OffsetDateTime
// How long is left in a usage window. Shared by the session bar and the usage screen: the
// arithmetic is the same in both and only the sentence around it differs, so everything here
// returns the span or the state on its own and leaves the wording to the caller.
// arithmetic is the same in both, so everything here returns the span or the state on its own and
// leaves the wording to the caller.
/**
* "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words.
*
* Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s
* left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the
* figure on a minute it has already spent -- so the reader watching the number decide whether to
* start something was consistently told less headroom than they had. One rule, so the session bar
* and the usage dialog cannot round a shared measurement two different ways.
* figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot
* round a shared measurement two different ways.
*/
fun formatSpan(until: Duration): String {
val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1)
@@ -31,14 +30,12 @@ fun formatSpan(until: Duration): String {
* Three answers rather than a nullable duration, because two of them shared `null` and they are not
* the same thing at all. A window the server sent no reset time for is one that is **not running**:
* the five-hour window is anchored to the block it started in, so between sessions there is nothing
* counting down and the API says so by omitting the field -- measured against a live response on
* 2026-08-31, where the five-hour window's reset was exactly five hours after the moment work
* resumed. A timestamp that did arrive and could not be read is the genuinely unknown case, and it
* is the only one worth those words.
* counting down and the API says so by omitting the field. A timestamp that did arrive and could
* not be read is the genuinely unknown case.
*
* Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on
* the one row somebody reads before starting something big -- and the usage dialog, looking at the
* same field, quietly drew nothing. Two rules for one missing value; this is the rule.
* same field, quietly drew nothing.
*/
sealed class WindowEnd {
/** No reset time was sent, so nothing is running in this window. Not a failure to find out. */
@@ -10,24 +10,21 @@ private const val ANCHORS = "session-scroll"
*
* Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by
* the row key the list draws with. An index means nothing across a reopen, since the transcript is
* fetched newest-first and a session that has said anything since has renumbered every position.
* The row key looks stable and is not: a tool row is named after its run, `joinPages` gives a run
* the name of its newest half, and the newest half is whatever the newest page happened to start
* with -- so an active session renames its tool runs every time it is reopened, and an anchor
* naming one is never found. A seq is the server's own numbering, assigned once and never moved.
* fetched newest-first. The row key looks stable and is not: a tool row is named after its run,
* `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest
* page started with -- so an active session renames its tool runs every time it is reopened. A seq
* is the server's own numbering, assigned once and never moved.
*
* [unit] is which unit of the row the viewport started at -- see [TranscriptUnit.ordinal] -- and
* [offset] how far that unit was scrolled past the viewport's newest edge, in pixels. A seq alone
* is not a place: a reply is one seq and can be forty blocks long, and a reader stopped halfway
* down it is put back at that block, not at the reply.
* [unit] is which unit of the row the viewport started at and [offset] how far that unit was
* scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can
* be forty blocks long.
*/
data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0)
/**
* On this device rather than on the backend, which is where this app otherwise keeps state so every
* device sees it. Scroll position is the same exception a draft is: it is where the phone in
* somebody's hand is pointed, and having one device jump because another was scrolled would be a
* surprise rather than a convenience.
* somebody's hand is pointed.
*/
fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val stored =
@@ -36,8 +33,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val fields = stored.split(':')
val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null
val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null
// Positions saved before the unit was recorded name the row's oldest unit, which is the
// closest older place -- the same choice [unitIndexFor] makes when a unit is gone.
// Positions saved before the unit was recorded name the row's oldest unit, which is the closest
// older place -- the same choice [unitIndexFor] makes when a unit is gone.
return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0)
}
@@ -45,9 +42,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
* Records where [sessionId] is being read, or forgets it when [anchor] is null.
*
* The path out is reading to the newest end, which is what the caller passes null for: a session
* left at the bottom has nothing to restore and should open at the bottom, which is also the cheap
* case. A session *deleted* while it held an anchor leaves its key behind, for the reason and at
* the cost `Drafts.kt` describes.
* left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its
* key behind, for the reason and at the cost `Drafts.kt` describes.
*/
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
@@ -14,10 +14,9 @@ typealias ServerSettings = com.example.wgapplink.ServerSettings
/**
* This app's enrollment, which is the whole of what is product-specific about it.
*
* Both values are load-bearing and neither may be changed casually. The scheme is what routes a
* scanned QR here rather than to Dev Updater, and the key alias names the Android Keystore key the
* token is already sealed under on every enrolled phone -- changing it would leave those phones
* reading as not enrolled, with no error to explain why.
* Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev
* Updater, and the key alias names the Android Keystore key the token is already sealed under on
* every enrolled phone -- changing it would leave those phones reading as not enrolled.
*/
private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key")
@@ -33,16 +33,14 @@ import androidx.lifecycle.repeatOnLifecycle
/**
* A session wanting attention, said over the app rather than through Android's drawer.
*
* Two places can carry the same fact and only one of them is right at a time. A row in the shade is
* for somebody looking at something else: it makes a sound, it waits however long it has to, and
* acting on it means leaving whatever they were doing. Somebody with this app open needs none of
* that -- they are already here, and what a tap on the notification would have done is what a tap
* on this does. So while these are on screen the stream is delivered here instead, which is
* arranged by the collection below and nothing else; see `NotificationService.forTheScreen`.
* Two places can carry the same fact and only one is right at a time. A row in the shade is for
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
* while these are on screen the stream is delivered here instead, which is arranged by the
* collection below and nothing else.
*
* A banner can go three ways, and each is somebody deciding something different: tapped, which
* opens the session; pushed off either side; or left alone, in which case it goes by itself when
* the bar across its foot runs out.
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
*/
@Composable
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
@@ -58,28 +56,26 @@ fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Mod
arrivals++
val alert = SessionAlert(notification, arrivals)
// One banner per session, replacing that session's own -- the same rule the
// drawer follows, and for the same reason: a session that finished and then
// asked a question is one thing to know about, the question. It keeps its
// place in the queue rather than moving to the end, because the reader may
// already be reaching for it.
// drawer follows: a session that finished and then asked a question is one
// thing to know about, the question. It keeps its place in the queue rather
// than moving to the end, because the reader may already be reaching for it.
val already = queue.indexOfFirst {
it.notification.sessionId == notification.sessionId
}
if (already >= 0) queue[already] = alert else queue.add(alert)
}
} finally {
// Leaving the app hands the job back to the drawer, so nothing arriving while it
// is away is lost. What would be lost is the truth of what is already up: these
// say a session wants somebody *now*, and one still sitting here on a return
// several minutes later is a claim nobody checked. Frozen, too -- Compose stops
// the clock with the window, so the timer that was going to retire it has been
// standing still the whole time.
// Leaving the app hands the job back to the drawer, so nothing arriving while it is
// away is lost. What would be lost is the truth of what is already up: these say a
// session wants somebody *now*, and one still sitting here on a return several
// minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock
// with the window.
queue.clear()
}
}
}
// Oldest at the top, so a new one appears below the ones already being read instead of
// shoving them down the screen mid-reach.
// Oldest at the top, so a new one appears below the ones already being read instead of shoving
// them down the screen mid-reach.
Column(modifier.fillMaxWidth().padding(8.dp)) {
queue.forEach { alert ->
key(alert.arrival) {
@@ -103,9 +99,7 @@ private data class SessionAlert(val notification: SessionNotification, val arriv
* One banner: what wants attention, and how long this has left to say so.
*
* The bar and the going away are one value rather than a bar beside a timer, because two of them
* would be two accounts of the same countdown and only one can be the one that fires. What is drawn
* is therefore the thing that decides, which is the only arrangement where a bar that has emptied
* cannot be sitting under a banner that is still there.
* would be two accounts of the same countdown and only one can be the one that fires.
*/
@Composable
private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) {
@@ -135,9 +129,7 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
),
// Outlined, because the step it needs to make is not one this palette can make with a
// tint: the card under a banner on the session list is the same surface, so a banner
// relying on colour alone reads as one more row that happens to be in the way. The
// border is the one cue, and the elevation beside it is the platform's shadow rather
// than a second tint -- Material draws no tonal overlay over a container stated here.
// relying on colour alone reads as one more row in the way. The border is the one cue.
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
) {
@@ -145,8 +137,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
Text(
alert.notification.title,
style = MaterialTheme.typography.titleSmall,
// One line, cut at the tail: a session is identified by the start of its
// name, and a banner that grew with the name would move the one below it.
// One line, cut at the tail: a session is identified by the start of its name,
// and a banner that grew with the name would move the one below it.
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -163,9 +155,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
LinearProgressIndicator(
progress = { life.value },
// Blue because it is reporting how much of something is left rather than passing
// judgement on it -- the reason `progressColor` exists. Stated beside the track,
// which is the card's own colour so that the spent part reads as empty rather
// than as a second bar.
// judgement on it. Stated beside the track, which is the card's own colour so that
// the spent part reads as empty rather than as a second bar.
color = progressColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
drawStopIndicator = {},
@@ -180,7 +171,6 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
* How long a banner stays if nobody touches it.
*
* Long enough to read a session name and a line, short enough that a stack of them clears itself
* while somebody is still on the screen that produced them. The bar makes the number visible, so
* this is a duration the reader can watch rather than one they have to learn.
* while somebody is still on the screen that produced them. The bar makes the number visible.
*/
private const val ALERT_LIFE_MS = 6_000
@@ -49,10 +49,9 @@ data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean)
/**
* Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling
* does not refetch.
*
* Shared by the transcript's images and the composer's pending attachments, because the fetch, the
* decode and the two-state answer are one block of logic that had been written twice.
* does not refetch. Shared by the transcript's images and the composer's pending attachments,
* because the fetch, the decode and the two-state answer are one block of logic that had been
* written twice.
*/
@Composable
fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap {
@@ -75,15 +74,12 @@ fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: Stri
* An image in the transcript: a fixed-height thumbnail that opens full screen.
*
* The height is decided before the bytes arrive and never changes. An image row that grew when it
* finished loading pushed everything below it, so a transcript being read scrolled itself while
* somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport
* moved the text under the reader's eyes. Reserving the final height makes loading invisible, which
* is what it should be.
* finished loading pushed everything below it, so a transcript being read scrolled itself -- and in
* a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes.
*
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
* than as a page of its own. Full size is one tap away -- but the full-size view itself is not
* here. [onOpen] hands the ref to the screen, which draws [SessionImageViewer] outside the list;
* see that function for the reason.
* than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the
* screen, which draws [SessionImageViewer] outside the list.
*/
@Composable
fun SessionImage(
@@ -97,9 +93,8 @@ fun SessionImage(
val heightPx = with(LocalDensity.current) { height.roundToPx() }
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
when (val image = bitmap) {
// Two states, not one: an image still arriving and an image that will never arrive
// look nothing alike to a reader who can do something about the second. So one gets a
// spinner in the space the picture is about to fill, and the other gets words.
// Two states, not one: an image still arriving and an image that will never arrive look
// nothing alike to a reader who can do something about the second.
null ->
if (failed) {
Text(
@@ -130,15 +125,12 @@ fun SessionImage(
* `Read` on its own is a row of one call, and the moment the next call arrives the two become a
* group -- a different composable in a different part of the tree, so everything the old subtree
* remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the
* transcript because the session made another tool call. The same happens to a row regrouped by a
* page of history landing.
* transcript because the session made another tool call.
*
* Held by the screen, none of that reaches it: what is open is a property of the screen, not of
* whichever row happened to draw the thumbnail.
* Held by the screen, none of that reaches it: what is open is a property of the screen.
*
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
* through. Paid deliberately rather than plumbed around: it is one request for a picture somebody
* asked to see, and the loading and unavailable states below are the same two the thumbnail draws.
* through. Paid deliberately: it is one request for a picture somebody asked to see.
*/
@Composable
fun SessionImageViewer(
@@ -157,9 +149,9 @@ fun SessionImageViewer(
contentAlignment = Alignment.Center,
) {
when (val image = bitmap) {
// Two states, not one, exactly as the thumbnail has them: still coming, and never
// coming. Stated in white because this box paints its own black behind them and a
// theme colour would be picked against a surface that is not there.
// Two states, not one, exactly as the thumbnail has them. Stated in white because
// this box paints its own black behind them and a theme colour would be picked
// against a surface that is not there.
null ->
if (failed) {
Text(
@@ -170,8 +162,7 @@ fun SessionImageViewer(
} else {
// The whole dialog is the area this picture is about to fill, so the
// spinner sits in the middle of it. White for the same reason the words
// beside it are: this box paints its own black, and a theme colour would
// be chosen against a surface that is not there.
// beside it are.
CircularProgressIndicator(color = Color.White)
}
else -> ZoomableImage(image)
@@ -185,11 +176,10 @@ fun SessionImageViewer(
*
* A square of the row's own height rather than the full width of the transcript: the height is what
* [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width
* placeholder would promise a picture wider than most of them turn out to be. Square is the closest
* thing to "the size of it" that can be drawn before knowing.
* placeholder would promise a picture wider than most turn out to be.
*
* Tinted, so the reader can see that something is being kept for a picture. That is also what
* distinguishes it from the failure beside it, which is words on the ordinary surface.
* Tinted, so the reader can see that something is being kept for a picture -- which is also what
* distinguishes it from the failure beside it, words on the ordinary surface.
*/
@Composable
private fun LoadingImage(height: Dp) {
@@ -210,8 +200,8 @@ private val LOADING_SPINNER = 24.dp
* Four lines of the body style the transcript is set in.
*
* Measured from the type rather than written as a dp, so it stays four lines when the text size
* changes -- including when the reader has scaled fonts up, which is exactly when a hardcoded
* height would be wrong.
* changes -- including when the reader has scaled fonts up, which is when a hardcoded height is
* wrong.
*/
@Composable
private fun thumbnailHeight(): Dp {
@@ -226,8 +216,7 @@ private fun thumbnailHeight(): Dp {
* Nearest neighbour when the image is being enlarged, smooth when it is being shrunk.
*
* A small image blown up with interpolation turns into a blur that hides what it is -- the same
* image with hard pixel edges stays readable. Shrinking wants the opposite, so this is a decision
* per image rather than a preference set once.
* image with hard pixel edges stays readable. Shrinking wants the opposite.
*/
private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality =
if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High
@@ -237,7 +226,7 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality
*
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
* visible, which is the thing a reader wants first; zoom is theirs from there.
* visible.
*/
@Composable
private fun ZoomableImage(image: ImageBitmap) {
@@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -52,22 +53,22 @@ fun SessionListScreen(
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Failures that belong to one session rather than to the list, keyed by
// its id and shown on its own card. The two scopes are decided by
// whether the server answered: it answered and refused, so this says
// nothing about the other rows, where a server that has stopped
// answering leaves every row stale and is `listState`'s to report.
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
// own card. The two scopes are decided by whether the server answered: it answered and refused,
// so this says nothing about the other rows.
//
// Cleared on the next successful load below -- an entry outlives its
// session otherwise, and would reappear against whatever the phone
// fetched next.
// Cleared on the next successful load below -- an entry outlives its session otherwise.
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which sessions have a delete in flight. A set of ids rather than a flag on the row,
// because the rows are rebuilt from whatever the server last said and this belongs to the
// request rather than to the session.
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
// the rows are rebuilt from whatever the server last said and this belongs to the request.
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
// This phone's copies of these sessions' transcripts, pruned from here because this is where a
// session stops existing. See TranscriptCache.
val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
fun refresh() {
listState = LoadState.Loading
scope.launch {
@@ -76,6 +77,13 @@ fun SessionListScreen(
val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap()
// The path out for a cached transcript whose session was deleted somewhere
// else. This list is the only place that ever learns the full set. On the
// answer rather than in `finally`: a list that failed to arrive says nothing
// about which sessions exist.
withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
}
loaded
} catch (e: ApiException) {
LoadState.failed(e)
@@ -89,12 +97,9 @@ fun SessionListScreen(
Column(Modifier.fillMaxSize().padding(16.dp)) {
when (val state = listState) {
is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is
// already a whole sentence naming the address and what to
// check, so a prefix here read "Couldn't reach the server:
// Couldn't reach the server at ...". It was also a guess --
// a delete that the server itself refused had reached it
// fine.
// The message as Api.kt wrote it, with nothing added: it is already a whole
// sentence naming the address and what to check, so a prefix here read "Couldn't
// reach the server: Couldn't reach the server at ...".
is LoadState.Error ->
Text(
state.message,
@@ -108,8 +113,7 @@ fun SessionListScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Awaiting-answer first (the point of the screen), then
// most recently active.
// Awaiting-answer first (the point of the screen), then most recently active.
val ordered =
state.value.sortedWith(
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
@@ -141,40 +145,37 @@ fun SessionListScreen(
confirmingDelete?.let { session ->
// Reset per session, so a toggle turned on for one conversation is not still on for the
// next one somebody opens this dialog for. Off to begin with: see [deleteSession].
// next. Off to begin with: see [deleteSession].
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Delete \"${session.title}\"?") },
text = {
// Two different acts behind one button, so it says which one this is. What
// separates them is whether the *driver* keeps its own record of the
// conversation -- the Claude Code CLI does, under ~/.claude/projects, whether
// this app spawned the session or imported it; echo and llama.cpp do not, and
// for those the app's transcript is the only copy there is.
// separates them is whether the *driver* keeps its own record of the conversation
// -- the Claude Code CLI does, whether this app spawned the session or imported it;
// echo and llama.cpp do not.
//
// This used to branch on `imported`, above a comment asserting that "a session
// started here has no copy anywhere". That was simply false for every
// claude-cli session this app spawned, and the two warnings disagreed about
// sessions that were equally recoverable. Getting it wrong in that direction
// is the expensive one: "this can't be undone", said of something that can,
// spends the credibility the sentence needs on the sessions where it is true.
// started here has no copy anywhere". That was false for every claude-cli session
// this app spawned, and getting it wrong in that direction is the expensive one:
// "this can't be undone", said of something that can, spends the credibility the
// sentence needs.
//
// Neither branch promises a restore. The recoverable one says what is known --
// the driver keeps its own record -- rather than that the file is still there,
// which nothing here checked; and it names what goes either way, because this
// app's transcript holds images, peer messages and commands that the CLI's own
// record never had.
// Neither branch promises a restore. The recoverable one says what is known -- the
// driver keeps its own record -- rather than that the file is still there, and it
// names what goes either way, because this app's transcript holds images, peer
// messages and commands the CLI's own record never had.
Column {
Text(
when {
!session.keepsOwnTranscript ->
"Kills the process and deletes the conversation. Nothing else " +
"keeps a copy, so this can't be undone."
// The sentence below is the one the toggle makes false, which is why
// it is written twice rather than appended to: leaving "should still
// be there to import again" on screen beside a switch that removes it
// is the reassurance being read at the moment it stops being true.
// The sentence below is the one the toggle makes false, which is why it
// is written twice rather than appended to: leaving "should still be
// there to import again" on screen beside a switch that removes it is
// the reassurance being read at the moment it stops being true.
alsoDeleteForeign ->
"Kills the process and deletes both copies of the conversation: " +
"this app's, and Claude Code's own transcript on the " +
@@ -189,12 +190,12 @@ fun SessionListScreen(
)
// Only where there is a second copy to decide about. Absent rather than
// disabled, because this is not a capability being withheld: for echo and
// llama.cpp there is no other transcript, and a switch offering to delete
// one would be asking about something that does not exist.
// llama.cpp there is no other transcript, and a switch offering to delete one
// would be asking about something that does not exist.
if (session.keepsOwnTranscript) {
Spacer(Modifier.height(16.dp))
// Its own row rather than beside the paragraph: a switch is taller than
// a line of text and re-centres whatever shares a row with it.
// Its own row rather than beside the paragraph: a switch is taller than a
// line of text and re-centres whatever shares a row with it.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Delete Claude Code's transcript too",
@@ -215,19 +216,20 @@ fun SessionListScreen(
onClick = {
confirmingDelete = null
// Marked here rather than after the request returns: the row has to say
// something is happening to it from the moment it is asked for, which
// is the whole of what this state is for.
// something is happening to it from the moment it is asked for.
deleting = deleting + session.id
deleteErrors = deleteErrors - session.id
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it.
transcriptCache.session(session.id).purge()
}
// Only this row, and only what changed. Refetching the list
// instead put every other session back through loading and
// handed the reader an empty screen -- to report on something
// that was never in doubt.
// Only this row, and only what changed. Refetching the list instead
// put every other session back through loading and handed the
// reader an empty screen, to report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState =
@@ -246,8 +248,8 @@ fun SessionListScreen(
}
}
) {
// Coloured by consequence: this takes something away, and does so wherever
// it appears -- the same rule the import screen's Delete follows.
// Coloured by consequence: this takes something away, and does so wherever it
// appears -- the same rule the import screen's Delete follows.
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
@@ -269,8 +271,7 @@ private fun SessionCard(
*
* Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its
* way out without claiming it has gone: a row removed the moment Delete is pressed is a promise
* about a request that has not been answered yet, and putting it back when the server refuses
* is worse than never having taken it away.
* about a request that has not been answered yet.
*/
deleting: Boolean,
onOpen: () -> Unit,
@@ -278,9 +279,9 @@ private fun SessionCard(
) {
BusyItem(label = if (deleting) "deleting" else null) {
Card(
// Off while the delete is in flight: a card that still opens a session it is
// deleting is a race the reader can start by tapping. On the card rather than in
// [BusyItem], which leaves gestures alone so the list still scrolls.
// Off while the delete is in flight: a card that still opens a session it is deleting
// is a race the reader can start by tapping. On the card rather than in [BusyItem],
// which leaves gestures alone so the list still scrolls.
Modifier.fillMaxWidth()
.combinedClickable(
enabled = !deleting,
@@ -303,9 +304,9 @@ private fun SessionCard(
Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
// Machine, then what runs on it, then what it is set to: the same order
// and separator as the session screen's header and the usage dialog, so
// one pair of facts is not written three ways.
// Machine, then what runs on it, then what it is set to: the same order and
// separator as the session screen's header and the usage dialog, so one
// pair of facts is not written three ways.
listOfNotNull(
session.setupName,
session.provider,
@@ -324,8 +325,7 @@ private fun SessionCard(
}
error?.let {
Spacer(Modifier.height(8.dp))
// The server's own words, unprefixed, the way every other
// failure in this app is shown.
// The server's own words, unprefixed, the way every other failure is shown.
Text(
it,
style = MaterialTheme.typography.bodySmall,
@@ -345,16 +345,16 @@ fun StatusText(status: String) {
"running" -> "running" to runningColor
"compacting" -> "compacting" to commandColor
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
// Said in words, because it differs in kind from the others rather than in degree:
// the session is not idle and has not exited, nobody has been able to find out
// which. A muted colour alone would read as one of the quiet states.
// Said in words, because it differs in kind from the others rather than in degree: the
// session is not idle and has not exited, nobody has been able to find out which. A
// muted colour alone would read as one of the quiet states.
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (sessionWorking(status)) {
// The same colour as the word beside it: the two are one signal, and a spinner in
// the theme's accent says the state is something other than what the label says.
// The same colour as the word beside it: the two are one signal, and a spinner in the
// theme's accent says the state is something other than what the label says.
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp,
File diff suppressed because it is too large. Load diff
@@ -39,12 +39,11 @@ import kotlinx.coroutines.withContext
* controls and hid the thing they act on.
*
* The model and the permission mode are deliberately still on the session's own bar, because those
* are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs
* with the thing it acts on.
* are changed *while* reading a turn -- "not this model, try that one".
*
* Nothing here is captioned. Each control is a labelled noun with a switch or a field beside it,
* and a paragraph under every one of them made the dialog longer than the conversation it covers.
* Failures still get their words: those are what the reader cannot work out by looking.
* Captions are for what a control costs rather than for what it is. A paragraph under every control
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
* and Reload do, because what those two take away is not visible from here.
*/
@Composable
fun SessionSettingsDialog(
@@ -55,7 +54,18 @@ fun SessionSettingsDialog(
*/
title: String,
onRenamed: (String) -> Unit,
/**
* What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it.
*/
cachedBytes: Long?,
onReload: () -> Unit,
onDismiss: () -> Unit,
/**
* Copies what this session costs to draw. Built by the session screen, because everything it
* measures is that screen's own state.
*/
onCopyRenderReport: () -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) }
@@ -63,16 +73,14 @@ fun SessionSettingsDialog(
var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
// whenever the list was last fetched, so drawing the switch straight from it would show a
// position that may have been changed since -- from here or from another device -- with
// nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside
// it, which is what not knowing looks like: distinguishable from off, and from a refusal.
// position that may have been changed since. Until the answer arrives the switch is disabled
// and a spinner sits beside it, which is what not knowing looks like.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) }
// Where the session works. Null until the server has been asked, for the same reason the
// switch above is: the row this dialog opened over is a snapshot, and a path drawn from it
// could be one somebody changed from another device. An empty answer is a session that was
// never given a directory, which is not the same as one whose directory is unknown -- the
// field is only enabled once one of those two is settled.
// Where the session works. Null until the server has been asked, for the same reason the switch
// above is. An empty answer is a session that was never given a directory, which is not the
// same as one whose directory is unknown -- the field is only enabled once one of those is
// settled.
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
var typedCwd by remember(sessionId) { mutableStateOf("") }
var cwdError by remember { mutableStateOf<String?>(null) }
@@ -85,8 +93,8 @@ fun SessionSettingsDialog(
cwd = fresh.cwd.orEmpty()
typedCwd = fresh.cwd.orEmpty()
} catch (e: ApiException) {
// Left unknown rather than falling back to the stale row: the switch stays
// disabled, instead of offering a position nothing confirmed.
// Left unknown rather than falling back to the stale row: the switch stays disabled,
// instead of offering a position nothing confirmed.
notifyError = e.message
notify = null
}
@@ -118,8 +126,8 @@ fun SessionSettingsDialog(
}
// Moved optimistically so the switch answers the finger that moved it, and put back if the
// request is refused -- a switch that waits for a round trip reads as broken on a slow
// tunnel, and one that stays moved after a refusal lies.
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
// and one that stays moved after a refusal lies.
fun setNotify(wanted: Boolean) {
val was = notify
notify = wanted
@@ -149,7 +157,7 @@ fun SessionSettingsDialog(
onRenamed(chosen)
} catch (e: ApiException) {
// Reported here, where it happened, because this dialog is the only place that
// knows a rename was attempted -- the session behind it shows nothing about it.
// knows a rename was attempted.
error = e.message
saving = false
}
@@ -168,8 +176,8 @@ fun SessionSettingsDialog(
singleLine = true,
enabled = !saving,
modifier = Modifier.fillMaxWidth(),
// The keyboard's own action does what the button does: a one-field form
// where the return key does nothing is a form people press return at anyway.
// The keyboard's own action does what the button does: a one-field form where
// the return key does nothing is a form people press return at anyway.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { save() }),
)
@@ -194,8 +202,8 @@ fun SessionSettingsDialog(
enabled = notify != null,
)
}
// Beside the switch that failed, not with the rename's error: they are two
// requests and a reader has to be able to tell which one the server refused.
// Beside the switch that failed, not with the rename's error: they are two requests
// and a reader has to be able to tell which one the server refused.
notifyError?.let {
Text(
it,
@@ -212,9 +220,9 @@ fun SessionSettingsDialog(
value = typedCwd,
onValueChange = { typedCwd = it },
label = { Text("Working directory") },
// What the field cannot say by being empty: a session that was never
// given one starts wherever its launcher does, and this names that
// rather than showing a path nobody chose.
// What the field cannot say by being empty: a session that was never given
// one starts wherever its launcher does, and this names that rather than
// showing a path nobody chose.
placeholder = { Text("wherever the session was started") },
singleLine = true,
enabled = cwd != null && !movingCwd,
@@ -234,9 +242,8 @@ fun SessionSettingsDialog(
}
}
// The whole of what pressing Move does, where it is about to be pressed. A
// directory is settled when the process is spawned, so there is no changing one
// under a running session -- it is ended, and the next thing said to the session
// starts it in the new place.
// directory is settled when the process is spawned, so it is ended and the next
// thing said to the session starts it in the new place.
Text(
"Moving stops the session's process. It starts again in the new directory " +
"with the next message, or with Start.",
@@ -250,6 +257,44 @@ fun SessionSettingsDialog(
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Transcript", modifier = Modifier.weight(1f))
// The size is what the button discards, and the unknown state is drawn rather
// than guessed: a spinner while the directory is being measured, and words when
// there is nothing there, because "nothing cached" and "0 B" read as different
// claims.
when {
cachedBytes == null ->
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
else ->
Text(
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(12.dp))
// Enabled whether or not anything is cached: "what I see disagrees with the
// machine" is a state an empty cache can be in too, and a control that comes
// and goes makes its own presence the signal.
TextButton(onClick = onReload) { Text("Reload") }
}
// Captioned, unlike the controls above it, for the same reason Move is: what it
// costs is not visible, and neither is the case it exists for.
Text(
"Reload throws away this phone's copy and fetches the transcript from the " +
"server again. Use it when what is shown here disagrees with the file " +
"on the machine.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
@@ -258,10 +303,24 @@ fun SessionSettingsDialog(
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
// About this session, which is what everything in here is -- and it was on the
// header until 2026-09-03, where the folder button now is. It copies rather than
// opening anything, so it says so and then says it happened: a row that looks like
// a control and gives no sign of having run is one people press twice.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("Render timings", modifier = Modifier.weight(1f))
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
}
}
},
// Disabled rather than absent while there is nothing to save: a button that comes and
// goes makes its own presence the signal, and its absence cannot say why.
// Disabled rather than absent while there is nothing to save: a button that comes and goes
// makes its own presence the signal, and its absence cannot say why.
confirmButton = {
TextButton(onClick = { save() }, enabled = changed && !saving) {
Text(if (saving) "Saving..." else "Save")
@@ -34,20 +34,19 @@ sealed class SessionUsage {
/**
* This machine meters nothing, so there is no window to show.
*
* Separate from [Unavailable], and the distinction is the whole point: a session on `echo` or
* on a local llama.cpp has no paid quota at all, which is a fact about how it was set up and
* not a failure to find something out. The backend never asks such a machine, so it returns no
* snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of
* answering with the nearest available word. Drawn as nothing, because there is nothing.
* Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a
* local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a
* failure to find something out. The backend never asks such a machine, and reading that
* silence as "couldn't find out" is answering with the nearest available word.
*/
data object NotMetered : SessionUsage()
/**
* The question could not be answered, and why.
*
* Its own state because "we couldn't find out" and "none of it is used" are the pair that must
* never share an appearance: a bar sitting at zero because a machine is unreachable reads as
* plenty of headroom, which is the opposite of the truth.
* Its own state because "we couldn't find out" and "none of it is used" must never share an
* appearance: a bar sitting at zero because a machine is unreachable reads as plenty of
* headroom.
*/
data class Unavailable(val why: String) : SessionUsage()
}
@@ -59,17 +58,14 @@ private const val REFRESH_MS = 60_000L
* One poll of every machine's limits, and the handle to ask again.
*
* A screen shows this answer in more than one place -- the bar under the session header, the colour
* of the button beside it, and the dialog that button opens -- and each of those used to fetch for
* itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole
* refresh interval old when the dialog opens with a fresh one, so the header read 42% while the
* screen over it read 47%, about a number somebody is deciding on. One feed per screen, and
* [refresh] moves both.
* of the button beside it, and the dialog that button opens -- and each used to fetch for itself.
* Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval
* old when the dialog opens with a fresh one, so the header read 42% while the screen over it read
* 47%.
*/
class UsageFeed(
val snapshots: LoadState<List<UsageSnapshot>>,
/**
* A fetch is outstanding. Only ever true over an answer already shown; see [rememberUsageFeed].
*/
/** A fetch is outstanding. Only ever true over an answer already shown. */
val refreshing: Boolean,
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
val refresh: () -> Unit,
@@ -93,15 +89,15 @@ class UsageFeed(
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
var refreshing by remember { mutableStateOf(true) }
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh
// also resets the countdown to the next one rather than leaving one due immediately after.
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also
// resets the countdown rather than leaving one due immediately after.
var asked by remember { mutableIntStateOf(0) }
LaunchedEffect(asked) {
while (true) {
refreshing = true
// Replaces the answer only once the next one is in hand: dropping back to Loading
// would blank a bar somebody is reading for the length of a round trip, and what was
// on screen is still the last thing the machine actually said.
// Replaces the answer only once the next one is in hand: dropping back to Loading would
// blank a bar somebody is reading for the length of a round trip, and what was on
// screen is still the last thing the machine actually said.
snapshots =
try {
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
@@ -121,13 +117,11 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
* Taken over however many windows came back rather than the three Claude sends today -- the backend
* deliberately passes windows it does not recognise straight through, so a fourth one is a thing
* that happens rather than a thing to notice later.
* passes windows it does not recognise straight through.
*
* Every state that is not a measurement takes the ordinary control colour instead. That is the
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
* unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind
* the button is where those say, in words, which one they are.
* unknown blue would say "measured, and fine" about a machine nobody could reach.
*/
@Composable
fun usageGlyphColour(usage: SessionUsage): Color =
@@ -145,18 +139,17 @@ fun usageGlyphColour(usage: SessionUsage): Color =
* going, and it was a screen away from the place that decision gets made. It reports on this
* session's machine alone -- the dialog is still where every machine is compared.
*
* What it shows is the paid service's own metering, fetched from the machine that holds the
* account. It is never derived from what this app has watched go past: the transcript's token
* counts are a different quantity, measured differently, and a bar shaped like a quota gauge built
* out of them would be a guess wearing a measurement's clothes.
* What it shows is the paid service's own metering, never derived from what this app has watched go
* past: the transcript's token counts are a different quantity, measured differently, and a bar
* built out of them would be a guess wearing a measurement's clothes.
*/
@Composable
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
DebugStats.count("usage bar recomposed")
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota
// happened to move would sit at a stale figure for hours.
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
// would sit at a stale figure for hours.
var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
@@ -165,8 +158,8 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
}
}
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would
// report a problem about a setup somebody chose, on every screen, forever.
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would report a
// problem about a setup somebody chose, on every screen, forever.
if (usage is SessionUsage.NotMetered) {
return
}
@@ -222,9 +215,8 @@ private fun UsageNote(text: String) {
* The percentage on its own does not answer the question it gets asked, which is whether to start
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
*
* The window's end has two missing cases and they are worded differently on purpose; see
* [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is
* no countdown to report and inventing one would be the same fault as inventing the number.
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
* that is not running gets the percentage and nothing else.
*/
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%"
@@ -244,8 +236,7 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
/**
* One machine's snapshot, out of every machine's.
*
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it:
* a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered.
*/
@@ -47,15 +47,14 @@ fun SettingsScreen(
val context = LocalContext.current
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
// Never pre-filled from the stored token: this screen shouldn't be a
// way to read the credential back off the device.
// Never pre-filled from the stored token: this screen shouldn't be a way to read the credential
// back off the device.
var token by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val scanLauncher =
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
// Null contents means the user backed out of the scanner -- not an
// error, so nothing to report.
// Null contents means the user backed out of the scanner -- not an error.
val contents = result.contents ?: return@rememberLauncherForActivityResult
val settings = parseEnrollmentUri(contents.toUri())
if (settings == null) {
@@ -83,8 +82,8 @@ fun SettingsScreen(
// left-pointing arrow at the right edge, aimed across the title it sits beside.
//
// Absent rather than disabled on first run, which is the one place this app lets a
// control come and go: there is no screen underneath yet, so a Back here would not be
// a capability being withheld but a promise it could not keep.
// control come and go: there is no screen underneath yet, so a Back here would not be a
// capability being withheld but a promise it could not keep.
if (onBack != null) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
@@ -106,14 +105,11 @@ fun SettingsScreen(
OutlinedButton(
onClick = {
// Hold the camera permission before the scanner starts.
// Letting its activity ask on our behalf is what the
// library does by default, and it opens the camera without
// waiting for the answer: the first-ever scan comes up as
// a live preview with "Sorry, the Android camera
// encountered a problem" over it, and works on the second
// try. Nothing is wrong with the camera, so nothing should
// say there is.
// Hold the camera permission before the scanner starts. Letting its activity ask on
// our behalf is what the library does by default, and it opens the camera without
// waiting for the answer: the first-ever scan comes up as a live preview with
// "Sorry, the Android camera encountered a problem" over it, and works on the
// second try.
if (
context.checkSelfPermission(Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
@@ -187,11 +183,10 @@ fun SettingsScreen(
* just been granted.
*
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a
* dark-themed terminal it comes out as a photographic negative the scanner silently never matches.
* Which way round it renders is the terminal's business, not something this app should depend on.
* The mixed decoder alternates normal and inverted frames, costing half the frame rate at each
* polarity and nothing else.
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark-
* themed terminal it comes out as a photographic negative the scanner silently never matches. The
* mixed decoder alternates normal and inverted frames, costing half the frame rate at each
* polarity.
*/
private fun enrollmentScanOptions(): ScanOptions =
ScanOptions()
@@ -58,8 +58,8 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
LaunchedEffect(reloadToken) { reload() }
Column(Modifier.fillMaxSize().padding(16.dp)) {
// The heading and Back are the tab row's now; adding a machine is this tab's own work
// and stays with the list it adds to.
// The heading and Back are the tab row's now; adding a machine is this tab's own work and
// stays with the list it adds to.
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { adding = true }) { Text("Add machine") }
}
@@ -200,9 +200,8 @@ private fun SetupCard(
Column(Modifier.padding(12.dp)) {
Text(setup.name, style = MaterialTheme.typography.titleSmall)
Text(
// Not "this machine": the seeded setup is *called* that,
// and the card read "this machine / this machine". The
// line has to say something the name cannot also be.
// Not "this machine": the seeded setup is *called* that, and the card read "this
// machine / this machine".
setup.address ?: "runs where the backend does",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -275,9 +274,8 @@ private fun AddSetupDialog(
OutlinedTextField(
value = address,
onValueChange = { address = it },
// Just the shape. What a blank one means is said once, in the text above
// this form -- repeating it here wrapped the label onto a second line and
// made this field taller than the two beside it for no information.
// Just the shape. What a blank one means is said once, in the text above this
// form -- repeating it here wrapped the label onto a second line.
label = { Text("user@host[:port]") },
singleLine = true,
)
@@ -288,8 +286,7 @@ private fun AddSetupDialog(
singleLine = true,
)
// Where a file attached from the phone lands on that machine. Blank means the
// session's own directory, which is what most people want and what needs no
// path typed on a phone.
// session's own directory, which is what most people want.
OutlinedTextField(
value = attachmentsDir,
onValueChange = { attachmentsDir = it },
@@ -309,9 +306,8 @@ private fun AddSetupDialog(
},
dismissButton = {
Row {
// Tried before saving, so a wrong address or an
// unauthorised key is caught while this form is still on
// screen rather than at the first spawn.
// Tried before saving, so a wrong address or an unauthorised key is caught while
// this form is still on screen rather than at the first spawn.
TextButton(
enabled = !testing,
onClick = {
@@ -377,14 +373,13 @@ private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String)
/**
* Splits `user@host:port` into its two halves, with the port left null when none was typed.
*
* One field rather than two because that is how an address is written and read everywhere else --
* and because a port that is almost always 22 does not deserve a box of its own on a phone
* keyboard. Null rather than 22: the backend already decides the default, and writing 22 here would
* put a second answer to that question in a second place.
* One field rather than two because that is how an address is written and read everywhere else, and
* because a port that is almost always 22 does not deserve a box of its own on a phone keyboard.
* Null rather than 22: the backend already decides the default.
*
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
* `[::1]:22`; a bare `::1` keeps every colon, because an address with several is an address, not an
* address and a port. So the rule is: brackets, or exactly one colon followed by digits.
* `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon
* followed by digits.
*/
private fun splitHostAndPort(typed: String): Pair<String, Int?> {
if (typed.startsWith("[")) {
@@ -9,7 +9,7 @@ import androidx.core.content.IntentCompat
*
* Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the
* share arrives before anyone has said which. [serial] makes two shares of the same thing two
* requests, for the reason [SessionOpenRequest] carries one: equal values would not recompose.
* requests, for the reason [SessionOpenRequest] carries one.
*/
data class ShareRequest(val uris: List<Uri>, val text: String?, val serial: Int)
@@ -0,0 +1,20 @@
package com.example.aiapp
/**
* A byte count at the coarsest unit that still says something, so rows stay comparable.
*
* Null at zero and below, because the screens that ask disagree about what nothing means and only
* the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no
* bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached
* transcript says "nothing cached", because a figure of none would read as a measurement.
*
* Its own file rather than the import screen's, where it started: three screens now say a size, and
* a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
*/
fun humanSize(bytes: Long): String? =
when {
bytes <= 0L -> null
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
bytes >= 1_000L -> "${bytes / 1_000L} kB"
else -> "$bytes B"
}
@@ -37,7 +37,7 @@ import kotlinx.coroutines.withContext
* The spawn screen: what to run, where to run it, and the per-kind fields.
*
* Providers and hosts both come from the server, so adding either to its config.ron shows up here
* with no app rebuild -- and because they are independent, any provider can be sent to any host.
* with no app rebuild.
*/
@Composable
fun SpawnScreen(
@@ -46,33 +46,27 @@ fun SpawnScreen(
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
// What the form is made of, and whether we have it yet. A failure here
// is not the same as a server with nothing to offer, so it must not
// reach the pickers as empty lists -- see LoadState.
// What the form is made of, and whether we have it yet. A failure here is not the same as a
// server with nothing to offer, so it must not reach the pickers as empty lists.
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
// Setup first, then one of its providers. Choosing a setup can
// invalidate the provider, so the provider is stored by name and
// resolved against the current setup rather than held as an object
// that could outlive the list it came from.
// Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the
// provider is stored by name and resolved against the current setup rather than held as an
// object that could outlive the list it came from.
var setupName by remember { mutableStateOf<String?>(null) }
var providerName by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") }
var cwd by remember { mutableStateOf("") }
// "auto" rather than "manual": on a phone every ask is a round trip to
// a question card, and answering "allow Bash?" dozens of times per task
// is what this app exists to avoid. Manual stays one tap away for a
// session that warrants it.
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
var permissionMode by remember { mutableStateOf("auto") }
var busy by remember { mutableStateOf(false) }
// Only the spawn's own failure. The fetch's lives in `options`: this
// one leaves a filled-in form worth keeping, and that one leaves
// nothing to fill in.
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
// form worth keeping, and that one leaves nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) }
// Downloaded models, for a llama provider to choose between. Fetched
// beside the setups but kept separate: a Claude session needs none, so
// failing to list them must not stop the screen rendering.
// Downloaded models, for a llama provider to choose between. Kept separate from the setups: a
// Claude session needs none, so failing to list them must not stop the screen rendering.
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") }
@@ -105,10 +99,9 @@ fun SpawnScreen(
}
Spacer(Modifier.height(16.dp))
// Nothing below is fillable until the options are here, and a
// failure to fetch them leaves no form worth showing -- so this
// reports and stops, rather than offering empty pickers under an
// error message.
// Nothing below is fillable until the options are here, and a failure to fetch them leaves
// no form worth showing -- so this reports and stops, rather than offering empty pickers
// under an error message.
val setups =
when (val state = options) {
is LoadState.Loading -> {
@@ -123,10 +116,9 @@ fun SpawnScreen(
}
val setup = setups.firstOrNull { it.name == setupName }
val current = setup?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and
// permission modes; keying the extra fields on the kind rather
// than the provider name keeps a second Claude provider from
// needing anything here.
// Only the Claude CLI has models, a working directory and permission modes; keying the
// extra fields on the kind rather than the provider name keeps a second Claude provider
// from needing anything here.
val isClaude = current?.kind == "claude_cli"
val isLlama = current?.kind == "llama_cpp"
@@ -137,9 +129,9 @@ fun SpawnScreen(
selected = setupName,
onSelect = { name ->
setupName = name
// The provider list changes with the machine, so a name
// carried over from the previous one would be a selection
// that isn't in the picker. Take that machine's first.
// The provider list changes with the machine, so a name carried over from the
// previous one would be a selection that isn't in the picker. Take that machine's
// first.
providerName =
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
},
@@ -150,13 +142,13 @@ fun SpawnScreen(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// The address belongs to the setup above it, not to the
// provider label below; without this they read as one block.
// The address belongs to the setup above it, not to the provider label below; without
// this they read as one block.
Spacer(Modifier.height(8.dp))
}
// Only what this machine actually has. A setup with none says so
// rather than showing an empty row that reads as a failure.
// Only what this machine actually has. A setup with none says so rather than showing an
// empty row that reads as a failure.
if (setup != null && setup.providers.isEmpty()) {
Text(
"\"${setup.name}\" has no providers configured.",
@@ -183,10 +175,9 @@ fun SpawnScreen(
)
if (isLlama) {
// A llama session names one of the models this backend has
// downloaded, so the choice is that list rather than free
// text -- there is nothing sensible to type here, and a name
// that is not on disk is a session that cannot start.
// A llama session names one of the models this backend has downloaded, so the choice is
// that list rather than free text -- a name that is not on disk is a session that
// cannot start.
if (models.isEmpty()) {
Text(
"No models downloaded yet. Get one from the Models screen first.",
@@ -196,9 +187,8 @@ fun SpawnScreen(
} else {
ChipGroup(
label = "Model",
// The file, not the whole key: the repository is the
// same for every quantisation of a model, so the file
// name is what tells two of them apart.
// The file, not the whole key: the repository is the same for every
// quantisation of a model, so the file name is what tells two of them apart.
options = models.map { it.file },
selected = models.firstOrNull { it.key == modelKey }?.file,
onSelect = { file -> modelKey = models.first { it.file == file }.key },
@@ -280,13 +270,9 @@ fun SpawnScreen(
withContext(Dispatchers.IO) {
spawnSession(
settings,
// The id, not the label: labels are
// editable and the server resolves by
// id.
// Non-null here: `chosen` came from
// `setup`'s own provider list, so
// reaching this point proves there was
// a setup to take it from.
// The id, not the label: labels are editable and the server
// resolves by id. Non-null here, since `chosen` came from
// `setup`'s own provider list.
setup = setup.id,
provider = chosen.name,
title = title.trim(),
@@ -294,9 +280,8 @@ fun SpawnScreen(
if (isLlama) modelKey else model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude },
// Sent only when set, so blank means
// "whatever llama.cpp does by default"
// rather than a zero.
// Sent only when set, so blank means "whatever llama.cpp does
// by default" rather than a zero.
params =
buildMap {
if (isLlama) {
@@ -17,14 +17,13 @@ const val RECONNECT_DELAY_MS = 1500L
* One server-sent-events connection, framed.
*
* The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank
* line ends the frame, comments (keep-alives) start with `:`, and a frame is either named with no
* payload or a payload with no name. Two screens follow two different streams a session's
* transcript and what a machine's import list is doing and neither should be re-deriving that.
* line ends the frame, comments start with `:`, and a frame is either named with no payload or a
* payload with no name. Two screens follow two different streams and neither should re-derive that.
*
* Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the
* cancellation path it disconnects the socket, which unblocks the read, and [run] then returns
* rather than throwing, so a deliberate close is not reported as a connection error. Reconnecting
* belongs to the caller, which is the only one that knows where to resume from.
* cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns
* rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where
* to resume from.
*/
class Sse(private val settings: ServerSettings) {
@Volatile private var connection: HttpURLConnection? = null
@@ -38,19 +37,16 @@ class Sse(private val settings: ServerSettings) {
/**
* Follows the stream at [path], handing each frame to [onFrame] as its name (null for an
* ordinary data frame) and its payload. The path is given here rather than at construction
* because a caller that reconnects usually resumes from somewhere new -- a cursor it has
* advanced past -- and that lives in the query string.
* because a caller that reconnects usually resumes from somewhere new.
*
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
* stream is live, and the only honest thing to clear a previous failure on: clearing on the
* first *event* instead left an idle stream displaying a connection error it had already
* recovered from, indefinitely.
* first *event* instead left an idle stream displaying an error it had already recovered from.
*/
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
// Opening is inside the try, not before it. Everything this method can fail at owes the
// caller the same kind of failure -- both callers retry an [ApiException] and let anything
// else reach the top of the app -- and a connection that could not even be constructed
// used to escape as a raw `IOException` from a line no `catch` covered.
// caller the same kind of failure, and a connection that could not even be constructed used
// to escape as a raw `IOException` from a line no `catch` covered.
var connection: HttpURLConnection? = null
try {
connection =
@@ -93,7 +89,7 @@ class Sse(private val settings: ServerSettings) {
if (!closed) {
throw ApiException(
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
e,
cause = e,
)
}
} finally {
@@ -1,70 +0,0 @@
package com.example.aiapp
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
/**
* Dragging the screen to the right to step back to the one behind it.
*
* The platform's own back gesture is a swipe from the very edge, and only from there; on a phone
* held in one hand the way back from a session is either that narrow strip or the arrow at the top
* left, which is the far corner from the thumb. This is the same movement from anywhere on the
* screen.
*
* **It loses every argument.** The gesture is a plain horizontal [draggable] on the outside of the
* screen, so anything inside that wants horizontal drags has already taken them by the time this
* would see them: pointer events reach the innermost node first, and a drag a child has consumed
* never crosses this modifier's touch slop. That is what keeps a wide code fence, a table scrolled
* sideways or a text selection working -- they are the components the reader meant, and this is
* only what is left over. Vertical drags are not its orientation, so the transcript scrolls
* untouched.
*
* The screen follows the finger rather than jumping at the end, because a gesture with no feedback
* cannot be aborted: the reader has to be able to see it starting and change their mind. Released
* short of [SWIPE_BACK_TRAVEL] it slides back and nothing happens. Right rather than left, and only
* right, since there is nothing forward of these screens to go to.
*/
@Composable
fun Modifier.swipeBack(onBack: () -> Unit): Modifier {
val offset = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val travel = with(LocalDensity.current) { SWIPE_BACK_TRAVEL.toPx() }
return draggable(
state =
rememberDraggableState { delta ->
// Rightward only: a leftward drag stays at zero rather than lifting the
// screen off its left edge, which would look like a gesture that does
// something and does not.
scope.launch { offset.snapTo((offset.value + delta).coerceAtLeast(0f)) }
},
orientation = Orientation.Horizontal,
onDragStopped = {
if (offset.value >= travel) {
onBack()
// Straight back rather than animated: the screen this was moving is being
// replaced, and animating it home first would show the old one sliding back
// into place after the new one had arrived.
offset.snapTo(0f)
} else {
offset.animateTo(0f)
}
},
)
// Read inside the block, so following the finger is a draw-phase change and costs no
// recomposition of the screen being dragged.
.graphicsLayer { translationX = offset.value }
}
/** How far the screen has to be pulled for letting go to mean "back" rather than "never mind". */
private val SWIPE_BACK_TRAVEL: Dp = 96.dp
@@ -44,16 +44,14 @@ private object Mocha {
*
* Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link*
* -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike
* is a preference, not a contract, and the moment one wants a different accent the shared version
* becomes a thing to fight rather than a thing to use.
* is a preference, not a contract.
*
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
* Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the
* page is Base, a component's outlined card stays Base beside it, and a project's card is Surface
* 0: one visible step up, which is the whole of what the nesting has to say.
* Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside
* it, and a project's card is Surface 0: one visible step up, which is the whole of what the
* nesting has to say.
*
* Accents on this palette are light, so anything filled with one takes Crust for its text rather
* than the near-white the roles default to.
* Accents on this palette are light, so anything filled with one takes Crust for its text.
*/
val AiAppColors =
darkColorScheme(
@@ -94,10 +92,8 @@ val AiAppColors =
* What a session is doing, said in colour.
*
* Here rather than beside each screen that shows a status. These were separate literals in two
* other files -- an amber, a green and a red picked off Material's defaults -- so the same state
* was a slightly different colour depending which screen you looked at, and none of them belonged
* to this palette at all. A colour that carries meaning is part of the scheme, not a value typed
* where it happened to be needed.
* other files, so the same state was a slightly different colour depending which screen you looked
* at. A colour that carries meaning is part of the scheme, not a value typed where it was needed.
*/
val runningColor: Color
@Composable get() = Mocha.Green
@@ -107,7 +103,7 @@ val runningColor: Color
*
* The scheme's error colour, and deliberately not "the same red as a destructive button" even
* though it is the same red. They are the same red for different reasons, and a state is not an
* action -- nothing here is a button.
* action.
*/
val failedColor: Color
@Composable get() = MaterialTheme.colorScheme.error
@@ -116,10 +112,9 @@ val failedColor: Color
* About the session rather than about the task: a command, and the compaction one of them starts.
*
* Its own colour because it is its own kind of work. Everything else a session does is progress
* through what was asked of it; this is the session acting on itself -- rewriting what it
* remembers, taking a new name -- and none of it appears in the transcript as an answer to
* anything. A reader who has learned that blue means "not stuck, but not replying to you either"
* has learned the thing that distinguishes it from a session that has hung.
* through what was asked of it; this is the session acting on itself, and none of it appears in the
* transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not
* replying to you either" has learned what distinguishes it from a session that has hung.
*/
val commandColor: Color
@Composable get() = Mocha.Blue
@@ -128,10 +123,9 @@ val commandColor: Color
* A clear: the conversation taken out of what the session is given.
*
* Red because of what it does, not because anything went wrong -- somebody asked for this, and a
* deliberate choice is not a problem to report. It is the same red as [failedColor] and [stopColor]
* for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a
* button, it is the mark left where something was taken away. The reader never has to tell the
* three apart, because no two of them can appear as the same kind of thing.
* deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a
* third reason: this is neither a fault nor a button, it is the mark left where something was taken
* away. No two of the three can appear as the same kind of thing.
*/
val clearedColor: Color
@Composable get() = Mocha.Red
@@ -148,9 +142,9 @@ val warningColor: Color
* The fill of a progress bar that is only reporting how far along something is.
*
* Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary
* made it the loudest thing on a screen the reader opened to do something else. A download, or a
* compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and
* that one is [quotaColor].
* made it the loudest thing on a screen the reader opened to do something else. A download has no
* limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is
* [quotaColor].
*/
val progressColor: Color
@Composable get() = Mocha.Blue
@@ -158,15 +152,13 @@ val progressColor: Color
/**
* The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red.
*
* One function rather than the same `when` written beside each bar, because the whole point of
* colouring by consequence is that the reader learns the step once -- two bars showing the same 80%
* in different colours teaches nothing except that the colour cannot be trusted. It reads as a
* difference in degree, which is all colour can carry: the states that differ in *kind* from this
* -- a window nobody could read, a machine that meters nothing -- are said in words elsewhere,
* because a reader has no way to tell those from an ordinary low number by colour alone.
* One function rather than the same `when` written beside each bar, because the point of colouring
* by consequence is that the reader learns the step once. It reads as a difference in degree, which
* is all colour can carry: the states that differ in *kind* -- a window nobody could read, a
* machine that meters nothing -- are said in words elsewhere.
*
* [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent
* without each converting it first and one of them getting it wrong by a factor of a hundred.
* without one of them getting it wrong by a factor of a hundred.
*/
@Composable
fun quotaColor(percent: Double): Color =
@@ -185,13 +177,11 @@ private const val OVER_LIMIT_PERCENT = 90.0
/**
* The surface verbatim text sits on: a command, a tool's output, a code block in a reply.
*
* The darkest value in the palette rather than a step up from the page, and that is the whole point
* -- everything else on this screen is somebody's prose, and this is what a machine was handed and
* The darkest value in the palette rather than a step up from the page, and that is the point --
* everything else on this screen is somebody's prose, and this is what a machine was handed and
* what it said back, character for character. Crust sits *below* Base, so the same colour reads as
* one clear step down both on the page, where a reply is drawn, and on a card, where a tool call
* is; a tint chosen upwards has to be picked twice and still collides with the card it lands on.
* The renderer's default code background was `surfaceVariant`, which is exactly a card's own fill
* -- so a code block inside a tool call had no background at all.
* one clear step down both on the page and on a card; a tint chosen upwards has to be picked twice
* and still collides with the card it lands on.
*
* One colour for all three, so "this is verbatim" is learnable once.
*/
@@ -202,11 +192,10 @@ val rawSurface: Color
* Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette].
*
* Here with the rest of the palette rather than beside the code that highlights: the colours a
* fence is drawn in are the same accents every other coloured thing in the app already uses, and
* splitting them out would make code the one surface whose palette came from somewhere else.
* fence is drawn in are the same accents every other coloured thing already uses.
*
* Not a composable, because [highlight] runs off the drawing thread; these colours never vary with
* the theme.
* Not a composable, because [highlight] runs off the drawing thread; these never vary with the
* theme.
*/
fun catppuccinSyntax(): SyntaxPalette =
SyntaxPalette(
@@ -227,8 +216,7 @@ fun catppuccinSyntax(): SyntaxPalette =
* already made for every other blue on the screen.
*
* Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which
* is upstream's choice and not an omission here. A program that uses bright red to mean something
* other than red is relying on a distinction its own terminal may not draw either.
* is upstream's choice and not an omission here.
*
* The background is [rawSurface] because that is what a tool's output is drawn on, and reverse
* video needs to know what it is reversing against.
@@ -263,14 +251,12 @@ fun ansiPalette(): AnsiPalette =
*
* The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app
* draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a
* code block or a tool's output, on Crust, the same 40% composites to a barely-there smudge, so
* selecting a line of code looks like nothing happened even though the selection is there and
* copies correctly.
* code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of
* code looks like nothing happened even though it copies correctly.
*
* Fixed and stronger, because "this is selected" is a meaning rather than decoration: a colour that
* means something must carry its own contrast instead of borrowing it from the surface it happens
* to land on. Raised only as far as it takes to read on the darkest of them -- past this the fill
* starts competing with the syntax colours it sits behind, which are the thing being read.
* Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only
* as far as it takes to read on the darkest of them -- past this the fill starts competing with the
* syntax colours it sits behind.
*/
val AiAppSelectionColors =
TextSelectionColors(
@@ -288,11 +274,10 @@ val linkColor: Color
* A list's markers: the bullets and numbers down its left edge.
*
* The scheme's secondary accent rather than the text colour, because a marker is structure rather
* than words: coloured, the items of a list can be counted without reading them, and a nested list
* reads as a shape before it reads as text. Lavender is not one of the colours that mean something
* here -- green, red, peach and yellow are states and actions -- and it is the same at every depth,
* since depth is said by the glyph and the indent; a colour per depth would make a difference in
* degree look like one in kind.
* than words: coloured, the items of a list can be counted without reading them. Lavender is not
* one of the colours that mean something here, and it is the same at every depth, since depth is
* said by the glyph and the indent -- a colour per depth would make a difference in degree look
* like one in kind.
*/
val listMarkerColor: Color
@Composable get() = Mocha.Lavender
@@ -305,11 +290,9 @@ val overLimitColor: Color
* The composer's buttons, coloured by what pressing one does rather than by where it sits.
*
* Green makes something happen now, blue makes it happen later, orange takes back what is in
* flight, red ends the process. The near-collisions with the states above are deliberate and worth
* naming rather than collapsing: [runningColor] is green because a session is working,
* [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session
* is waiting on somebody -- those are *states*, and these are *actions*. A reader never has to tell
* them apart, because nothing here is a state and nothing there is pressable.
* flight, red ends the process. The near-collisions with the states above are deliberate: those are
* *states*, and these are *actions*. A reader never has to tell them apart, because nothing here is
* a state and nothing there is pressable.
*/
val sendColor: Color
@Composable get() = Mocha.Green
@@ -322,9 +305,8 @@ val queueColor: Color
* Interrupting the running turn: the work stops and the session stays.
*
* Orange rather than red because of how much it takes: only what is in flight. The process is still
* there holding the conversation, and the next message starts a turn as though nothing had
* happened. Red is spent on [stopColor], which is the same button in the same place when what it
* would end is the session's process.
* there holding the conversation. Red is spent on [stopColor], which is the same button in the same
* place when what it would end is the session's process.
*/
val pauseColor: Color
@Composable get() = Mocha.Peach
@@ -347,8 +329,7 @@ val startColor: Color
*
* The content colour is stated here beside the fill rather than inherited. A semantic colour has to
* carry its own contrast: these fills are fixed whatever the surface under them does, so the theme
* will not change to rescue a foreground that stops being readable on one of them. Crust is what
* every accent on this palette takes, which is the same reason `onPrimary` is Crust above.
* will not change to rescue a foreground that stops being readable on one of them.
*/
@Composable
fun actionButtonColors(fill: Color): ButtonColors =
@@ -16,11 +16,10 @@ import org.json.JSONObject
/**
* A tool call's input, read rather than dumped.
*
* Every tool's input arrives as JSON, and showing it raw makes the reader parse `{"command":"",
* "timeout":120000}` themselves to find the one line they care about. So the fields that carry the
* meaning are pulled out -- the command a shell will run, what it is for, how long it may take --
* and anything left over is still shown, because dropping a field would be claiming the tool has no
* other input when it might.
* Every tool's input arrives as JSON, and showing it raw makes the reader parse
* `{"command":"","timeout":120000}` themselves to find the one line they care about. So the fields
* that carry the meaning are pulled out, and anything left over is still shown, because dropping a
* field would be claiming the tool has no other input when it might.
*/
data class ToolInput(
/** The thing that will actually be run or read, if this tool has one. */
@@ -30,8 +29,8 @@ data class ToolInput(
/** The tool's own one-line summary, when it wrote one. */
val description: String?,
/**
* How long the call may take, in the largest units it fits ([formatMillis]). Shown apart
* because it is a limit on the call rather than part of what the call does.
* How long the call may take, in the largest units it fits. Shown apart because it is a limit
* on the call rather than part of what the call does.
*/
val timeout: String?,
/** Everything else, as `name: value` lines. Never dropped. */
@@ -47,7 +46,7 @@ data class ToolInput(
*
* A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them
* from being the special case that gets its own code path. Unknown tools fall through to "no
* subject, everything is rest", which is what the card always did.
* subject, everything is rest".
*/
private val SUBJECTS: Map<String, Pair<String, Language?>> =
mapOf(
@@ -68,8 +67,8 @@ fun parseToolInput(tool: String, input: String): ToolInput {
try {
JSONObject(input)
} catch (_: org.json.JSONException) {
// Not an object: older transcripts and some tools send a bare
// string. It is still the input, so it is still shown.
// Not an object: older transcripts and some tools send a bare string. It is still the
// input, so it is still shown.
return ToolInput(
null,
null,
@@ -100,9 +99,9 @@ fun parseToolInput(tool: String, input: String): ToolInput {
/**
* A tool call's input: its subject highlighted, then whatever else it carried.
*
* On the dark surface every verbatim thing in the app sits on -- see [RawBlock]. Drawn as nothing
* at all when the call carried neither, rather than as an empty block: a tinted rectangle with
* nothing in it is a rendering fault, and it is the shape a tool with no input actually has.
* On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the
* call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a
* rendering fault.
*
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
@@ -113,11 +112,11 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
if (parsed.subject == null && parsed.rest.isEmpty()) return
RawBlock(modifier) {
parsed.subject?.let { subject ->
// Not wrapped: a wrapped command hides where its arguments end,
// and the long one is the one being read closely.
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely.
Text(
// Not cached: a tool's subject is one command line, which lexes in microseconds
// -- the cache exists for a fence with two hundred lines in it.
// Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it.
remember(subject, parsed.language) { highlight(subject, parsed.language) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
@@ -42,14 +42,11 @@ import androidx.compose.ui.unit.dp
* the transcript's own order is what paging and the event stream depend on, and one screen's idea
* of "these belong together" must not reach back into it.
*
* Immutable, and said so, because Compose cannot tell.
*
* A row is a value: it is rebuilt from the transcript rather than edited, and two rows describing
* the same events are equal. Compose infers stability from a class's fields, and a `List` field --
* which several of these carry -- makes it assume the worst, so every composable taking one
* recomposed whenever anything above it did. A page of history landing recomposed all 148 loaded
* rows including the markdown inside them, measured as 701 compositions for 148 rows in one scroll,
* and that is what a page landing costs on top of the fetch itself.
* Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather
* than edited, and two rows describing the same events are equal. Compose infers stability from a
* class's fields, and a `List` field -- which several of these carry -- makes it assume the worst,
* so a page of history landing recomposed all 148 loaded rows including the markdown inside them,
* measured as 701 compositions for 148 rows in one scroll.
*
* The promise this makes is real and has to stay true: nothing here is mutated after it is built.
*/
@@ -59,16 +56,12 @@ sealed class TranscriptRow {
* This row's identity in the list, which must survive everything that can happen to the row.
*
* The list is keyed by this so that inserting a new message at one end, or a page of history at
* the other, moves the rows and not the reader. That makes it the load-bearing value on this
* screen: when a key changes, the list loses its anchor and the transcript steps under whoever
* is reading it.
* the other, moves the rows and not the reader. When a key changes, the list loses its anchor
* and the transcript steps under whoever is reading it.
*
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
* and it is the *same* value whether the run is drawn as one card or as a group. A lone call
* that gains a neighbour becomes a group without changing identity, which is the case a
* seq-based key got wrong: the row the reader was looking at was replaced rather than updated.
* Which value that is belongs to the item ([TranscriptItem.key]), not to a `when` here: a row
* is one item and the item is what knows what it is called.
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
* that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
*/
abstract val key: Any
@@ -76,12 +69,9 @@ sealed class TranscriptRow {
* Where this row starts in the transcript: the sequence number of the oldest event behind it.
*
* Separate from [key], and deliberately so. [key] is the list's identity and is a display
* decision -- a tool row is named after its run, and a run takes its name from whichever call
* was first when it was folded, which changes as pages arrive. A seq is the server's own
* numbering: it is assigned once, never moves, and means the same thing to every device. So
* anything that has to point at a place in the conversation and still find it later -- a saved
* scroll position is the one -- points with this, and anything that has to identify a row
* within one composition uses [key].
* decision; a seq is the server's own numbering, assigned once and meaning the same thing to
* every device. So anything that has to point at a place in the conversation and still find it
* later -- a saved scroll position -- points with this.
*/
abstract val startSeq: Long
@@ -133,8 +123,7 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
// Adjacency is the same answer most of the time and a worse one at the edges: a call
// arriving next to an existing run, or a page of history arriving in front of one, both
// change which call is *first*, and a group named after its first member is a different
// group every time that happens.
// change which call is *first*.
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
run += item
} else {
@@ -152,18 +141,14 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
* What says the calls belong together is the surface behind them, which is the one cue rather than
* two half-cues -- rounded to the same corner every other card in the app has, so a group reads as
* one object rather than as a square patch behind round things. The calls sit on it inset by
* [GROUP_INSET], which is the container's own padding rather than an indent: they are the same rows
* they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a
* notch.
* [GROUP_INSET], which is the container's own padding rather than an indent.
*
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
* the run reads as one thing broken into its parts; [GROUP_GAP] keeps the parts legible without
* separating them. See [connectedShape].
* the run reads as one thing broken into its parts; see [connectedShape].
*
* It closes from either end. A long group's header scrolls off while its last call is still on
* screen, and the reader who wants it shut is looking at the bottom, not hunting for the top. The
* bar at the foot is the same height as the heading at the top, so the surface the calls sit on is
* as thick below them as above.
* screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the
* same height as the heading at the top.
*/
@Composable
fun ToolGroup(
@@ -171,8 +156,7 @@ fun ToolGroup(
expanded: Boolean,
/**
* Where it was pressed is the row's business rather than the control's -- a group has a control
* at each end, and only the row knows where its own ends are, so the row records the touch
* itself and this just says that one happened.
* at each end, and only the row knows where its own ends are.
*/
onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean,
@@ -222,8 +206,8 @@ fun ToolGroup(
)
}
}
// Shutting it from here anchors the other end: the reader is at the bottom of a long
// group, and what they are looking at is what follows it.
// Shutting it from here anchors the other end: the reader is at the bottom of a long group,
// and what they are looking at is what follows it.
CollapseBar(barHeight, onToggle)
}
}
@@ -232,8 +216,8 @@ fun ToolGroup(
* The height of a group's heading, and so of the bar at its foot.
*
* Derived from the type the heading is set in rather than written down, because the two have to
* match and a pair of numbers chosen to look equal stops being equal the moment either the style or
* the density changes. Taking the line height also means the heading cannot be clipped by it.
* match and a pair of numbers chosen to look equal stops being equal the moment the density
* changes.
*/
@Composable
private fun groupBarHeight(): Dp {
@@ -242,10 +226,9 @@ private fun groupBarHeight(): Dp {
}
/**
* The bottom half of a group's toggle: an arrow back up to its heading.
*
* Given the heading's height rather than padded to something that looks close, so the surface the
* calls sit on is the same thickness at both ends. See [groupBarHeight].
* The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height
* rather than padded to something that looks close, so the surface the calls sit on is the same
* thickness at both ends.
*/
@Composable
private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
@@ -266,8 +249,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
* does not.
*
* Written once and given an index rather than branched at each end, because a stack has three cases
* that are one rule -- and the middle one is the case a hand-written first/last pair gets wrong
* when a run turns out to have three calls in it.
* that are one rule -- and the middle one is what a hand-written first/last pair gets wrong.
*/
@Composable
private fun connectedShape(index: Int, count: Int): CornerBasedShape {
@@ -294,12 +276,10 @@ private val GROUP_GAP = 2.dp
* One tool call.
*
* Closed, it is a single line: the tool's name and what the call is for. The command itself is not
* on it, because a wrapped command turns one row into four and a run of them into a wall -- and the
* name plus the intent is what somebody scanning the transcript is reading for.
* on it, because a wrapped command turns one row into four and a run of them into a wall.
*
* Open, it shows the command, whatever else the input carried, and the output. The timeout sits at
* the top right: it is a limit on the call rather than part of what the call does, and it is worth
* seeing beside the command it constrains rather than buried in the fields below it.
* the top right: it is a limit on the call rather than part of what the call does.
*
* A call waiting on permission is shown open whatever the reader last chose, since the command is
* the thing being decided and a row saying only "Bash" cannot be decided on.
@@ -342,10 +322,9 @@ fun ToolCard(
)
} ?: Spacer(Modifier.weight(1f))
}
// A spinner says the machine is working. While this call is waiting on an
// answer the machine is doing nothing at all -- the turn is stopped on the
// person reading it -- so it says whose move it is instead, in the colour this
// app uses everywhere for that.
// A spinner says the machine is working. While this call is waiting on an answer
// the machine is doing nothing at all -- the turn is stopped on the person reading
// it -- so it says whose move it is instead.
if (deciding) {
Spacer(Modifier.width(8.dp))
Text(
@@ -370,9 +349,9 @@ fun ToolCard(
modifier = Modifier.padding(top = 4.dp),
)
}
// Everything AskUserQuestion carries is the questions, and those are drawn
// below as something answerable; dumping the same JSON above them would be the
// decision stated twice, once unreadably.
// Everything AskUserQuestion carries is the questions, and those are drawn below as
// something answerable; dumping the same JSON above them would be the decision
// stated twice, once unreadably.
if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
}
@@ -381,14 +360,12 @@ fun ToolCard(
Text("Output", style = MaterialTheme.typography.labelSmall)
// What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is
// prose -- a directory listing, a diff, a table of numbers -- and a
// proportional font silently destroys the alignment that carried the meaning.
// prose, and a proportional font silently destroys the alignment that carried
// the meaning.
//
// Its terminal styling applied and the rest of the escapes taken out, since
// what a shell prints is written for a terminal: colour is often the whole of
// what a diff or a test run is saying, and the sequences that carry it are
// unreadable drawn verbatim. Remembered against the text, so a card that is
// open through a scroll parses once. See [ansiStyled].
// Its terminal styling applied and the rest of the escapes taken out: colour is
// often the whole of what a diff or a test run is saying. Remembered against
// the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() }
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
RawBlock(Modifier.padding(top = 2.dp)) {
@@ -400,10 +377,8 @@ fun ToolCard(
}
}
}
// Shown open or closed. A call that produced a picture is one
// whose result *is* the picture, and a row that hides it says
// less than the one line it replaced -- unlike a command, which
// is what the closed line already summarises.
// Shown open or closed. A call that produced a picture is one whose result *is* the
// picture, and a row that hides it says less than the one line it replaced.
tool.images.forEach { ref -> image(ref) }
if (tool.asks.isNotEmpty()) {
if (tool.tool == ASK_USER_QUESTION) {
@@ -428,11 +403,10 @@ private fun PermissionAsk(
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) {
// What was pressed, before the answer has been round-tripped. Two bare words with no submit
// step -- unlike a question card, where the answer is several choices and worth reviewing --
// so the press has to be its own acknowledgement or the row sits unchanged for a round trip
// and reads as having missed the tap. Cleared when the request settles: by then either the
// answer is in `ask.answers` and the mark stands on a measurement, or it failed and the
// buttons come back rather than leaving a decision marked that nothing recorded.
// step -- unlike a question card, where the answer is worth reviewing -- so the press has to be
// its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request
// settles: by then either the answer is in `ask.answers`, or it failed and the buttons come
// back.
var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
Spacer(Modifier.height(8.dp))
Text(
@@ -442,8 +416,7 @@ private fun PermissionAsk(
)
// Answered or not, the options stay and the one that was taken is marked -- see
// [AskedQuestion], which is the same rule on the question card. A permission is where it
// matters most: "Answered: Deny" alone does not say that Allow was the alternative, and
// whether a tool was allowed or refused is the thing a reader comes back to this row for.
// matters most: "Answered: Deny" alone does not say that Allow was the alternative.
val settled = ask.answers.isNotEmpty()
AnswerOptions(
ask.options,
@@ -0,0 +1,589 @@
package com.example.aiapp
import android.util.Log
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.io.RandomAccessFile
/**
* This phone's copy of the transcripts it has already been sent, so reopening a session does not
* download it again.
*
* What is stored is the server's own JSON for one event per line, in transcript order. Reading the
* cache means running the same [parseSeqEvent] the network path runs, so a cached transcript and a
* fetched one cannot draw differently, and an event type this build does not know keeps every field
* it arrived with for the build that will. Rows are deliberately *not* what is stored: a row is a
* rendering, and a cache of rows would need throwing away on every update that touched `foldEvent`.
*
* See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it:
* 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the
* cache is a copy and is never inferred, folded or edited here;
* 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache;
* 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a
* cold open, never to a blank or a wrong screen; 4. a line already on the phone is not fetched
* again.
*
* A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs
* under the JVM unit tests. That is also why there is no JSON parser here: what it needs off a line
* is the sequence number and whether the line is a streamed delta, both read with a regex. A line
* it cannot read that way is treated as damage. [warn] is where failures are said for the same
* reason.
*/
class TranscriptCache(
private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/** The cache for one session, whether or not anything has been stored for it yet. */
fun session(id: String): SessionCache = SessionCache(File(root, id), warn)
/**
* Deletes every session directory not in [ids], called after a successful list fetch. The path
* out for a session deleted on another device: nothing here would otherwise hear about it, and
* unlike a draft's few bytes what it leaves behind is megabytes.
*/
fun retainOnly(ids: Set<String>) =
guardIo(Unit, warn) {
sessionDirs().forEach { if (it.name !in ids) it.deleteRecursively() }
}
/**
* Deletes least-recently-touched session directories, never [keep], until the whole of this
* server's cache is under [budget]. Least-recently-touched rather than largest: what a reader
* is likely to open again is what they opened last, and evicting the big ones first would empty
* the cache for exactly the conversations it exists for.
*/
fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) =
guardIo(Unit, warn) {
val dirs = sessionDirs().sortedBy { it.lastModified() }
var total = dirs.sumOf { sizeOf(it) }
for (dir in dirs) {
if (total <= budget) break
if (dir.name == keep) continue
val was = sizeOf(dir)
if (dir.deleteRecursively()) total -= was
}
}
fun purgeAll() = guardIo(Unit, warn) { root.deleteRecursively() }
private fun sessionDirs(): List<File> = root.listFiles()?.filter { it.isDirectory }.orEmpty()
}
/**
* How much of this phone's cache directory all of one server's transcripts may take. A dozen of the
* largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small fraction of a phone.
* A number to revisit against real use rather than a measurement of anything.
*/
const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000
/**
* What the newest cached line says, which is what the probe checks against the server. Both halves
* are wanted together: the seq is what the request asks about, and the line is what its answer is
* compared with.
*/
data class CachedTail(val seq: Long, val line: String)
/**
* One session's cached lines, as a directory of chunks.
*
* A chunk is a set of lines *and a claim about what they cover*, and the two are not the same
* thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the
* run's oldest delta, so a page whose newest event is seq 1,200 may cover everything up to the
* 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open range
* in the file's name:
* ```
* <first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
* <first>-<end>.raw.jsonl an uncoalesced page, or a closed live run <first>-open.raw.jsonl the
* live run; end is its last line's seq + 1
* ```
*
* Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run ending
* at the newest chunk -- the **suffix** -- is ever served: chunks behind a gap are kept, because
* the gap is usually closed by paging back through it, but nothing is served across one.
*
* **The newest chunk is always raw**, which is what makes the stream cursor and the probe well
* defined. It holds by construction (the opening window and every stream frame are raw) and is
* checked on read: a `.rows` chunk at the newest end can only mean this app died between closing
* one live run and opening the next, and it discards the session.
*
* Nothing here is load-bearing. Every operation that touches the disk answers as though the cache
* were empty when it cannot, and a write failure disables writing for the rest of this instance's
* life so that a full disk costs one log line rather than one per delta.
*
* Every operation is synchronized, because two of them really do run at once: the stream appends
* live events from its own IO thread while a reader scrolling back reads pages from another. What
* it buys is that the open chunk's name, its end and its writer are never read half-rotated.
*/
class SessionCache(
private val dir: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/** Set by the first write that fails: a second would fail the same way, once per delta. */
private var disabled = false
/**
* The open chunk's writer, its file, and the seq that chunk now ends at.
*
* Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of
* times a second while a reply streams. What that costs is the unflushed tail on a crash, which
* is safe: a shorter cache is a longer catch-up, never a wrong one.
*/
private var writer: BufferedWriter? = null
private var openFile: File? = null
private var openEnd: Long = 0
/**
* The newest line of the suffix, or null when there is none or the newest chunk is not raw.
*
* This is the cursor the live stream would resume from, so it is also what has to be shown to
* still be the server's own line before anything is resumed from it.
*/
@Synchronized
fun tail(): CachedTail? =
guard(null) {
val newest = suffix().lastOrNull() ?: return@guard null
var found: CachedTail? = null
eachLine(newest) { line ->
found = CachedTail(seqOf(line)!!, line)
false
}
found
}
/** The newest [limit] lines of the suffix, oldest first -- the opening window. */
@Synchronized
fun newest(limit: Int): List<String> =
guard(emptyList()) {
val taken = ArrayDeque<String>()
for (chunk in suffix().asReversed()) {
if (taken.size >= limit) break
eachLine(chunk) { line ->
taken.addFirst(line)
taken.size < limit
}
}
taken.toList()
}
/**
* The page of lines before [before], oldest first, or null when the cache cannot answer.
*
* Null is a miss -- the suffix does not cover the ground immediately below [before] -- and
* means the server has to be asked. Deliberately not an empty list: an empty page is how the
* screen is told it has reached the start of the conversation, and a cache saying that of
* history it merely does not hold would stop the transcript scrolling back for good.
*
* [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open
* leaves behind is in the middle of the live run, so a cache that could only answer at a
* boundary would send the very first backwards page to the server and, since that page would
* overlap the run, keep none of it.
*
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`.
* The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of
* its first delta either way.
*/
@Synchronized
fun page(before: Long, limit: Int, rows: Boolean): List<String>? =
guard(null) {
val suffix = suffix()
val newest = suffix.lastOrNull() ?: return@guard null
// Above what is held, or at or below where it starts: either way the run the caller is
// scrolling into is not continuous with this one, and only the server has it.
if (before > newest.end || before <= suffix.first().first) return@guard null
val taken = ArrayDeque<String>()
var counted = 0
var inRun = false
var wanting = true
for (chunk in suffix.asReversed()) {
if (!wanting) break
if (chunk.first >= before) continue
eachLine(chunk) { line ->
// The page is what is *before* the cursor; the rows at or above it are already
// on screen.
if (seqOf(line)!! >= before) return@eachLine true
if (rows) {
val delta = isDelta(line)
// Stop only between rows: a delta continuing the run being gathered is part
// of a row already counted, and breaking on it would drop the half of that
// row already taken.
if (counted >= limit && !(delta && inRun)) wanting = false
else {
if (!delta || !inRun) counted++
inRun = delta
}
} else if (taken.size >= limit) {
wanting = false
}
if (wanting) taken.addFirst(line)
wanting
}
}
taken.toList()
}
/**
* The `end` of the nearest chunk at or below [before], which is the floor a fetched page is
* asked with so that it stops where this phone's copy starts. Null when there is no such chunk.
*
* Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that
* the gap is closed with exactly the bytes it is wide.
*/
@Synchronized
fun coveredUpTo(before: Long): Long? =
guard(null) { chunks().map { it.end }.filter { it <= before }.maxOrNull() }
/**
* Stores a fetched page covering `[first, end)`; false when it was not stored.
*
* Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced
* event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from
* arising by bounding what it fetches, and this is the guard for a page that arrives anyway.
* Such a page is still drawn; it is only not kept.
*
* The newest chunk is never stored through here: the opening window and every live frame go
* through [append], which is what keeps the newest chunk raw and open.
*/
@Synchronized
fun storePage(lines: List<String>, first: Long, end: Long, rows: Boolean): Boolean =
guard(false) {
if (disabled || lines.isEmpty() || end <= first) return@guard false
if (chunks().any { first < it.end && it.first < end }) return@guard false
dir.mkdirs()
val kind = if (rows) "rows" else "raw"
File(dir, "$first-$end.$kind.jsonl").writeText(lines.joinToString("\n", postfix = "\n"))
true
}
/**
* Appends one live event, which is also how a freshly fetched opening window is stored.
*
* A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a
* `reset` looks like from here -- and closes the open chunk under the end it turned out to
* have. A smaller one is already covered and is ignored; the SSE contract is `seq > after`.
*/
@Synchronized
fun append(line: String, seq: Long) =
guard(Unit) {
if (disabled) return@guard
val writer = writerFor(seq) ?: return@guard
// Written as it arrived. A newline inside it would split one event into two unreadable
// halves, but neither source can produce one: SSE framing forbids it, and a page's
// elements are re-serialized compactly, which escapes it.
writer.write(line)
writer.write("\n")
openEnd = seq + 1
}
/**
* Flushes what [append] has buffered. Called on each `Status` event -- the boundaries of a
* turn, which is the granularity a crash may as well lose -- and when the stream closes.
*/
@Synchronized fun flush() = guard(Unit) { writer?.flush() }
/** What [purge] would discard, for the reload row in session settings. */
@Synchronized fun bytes(): Long = guard(0L) { sizeOf(dir) }
/** Marks this session as visited, which is what eviction ranks by. */
@Synchronized
fun touch() =
guard(Unit) { if (dir.isDirectory) dir.setLastModified(System.currentTimeMillis()) }
@Synchronized
fun purge() =
guard(Unit) {
closeWriter()
dir.deleteRecursively()
}
// -- chunks ------------------------------------------------------------------------------
private data class Chunk(val file: File, val first: Long, val end: Long, val open: Boolean) {
val rows: Boolean
get() = file.name.endsWith(".rows.jsonl")
}
/**
* Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored.
* Recomputed per operation rather than kept: another operation may have changed the directory.
*/
private fun chunks(): List<Chunk> {
writer?.flush()
return dir.listFiles()
.orEmpty()
.mapNotNull { file ->
val match = CHUNK_NAME.matchEntire(file.name) ?: return@mapNotNull null
val first = match.groupValues[1].toLongOrNull() ?: return@mapNotNull null
val open = match.groupValues[2] == "open"
val end = if (open) openEndOf(file, first) else match.groupValues[2].toLongOrNull()
// A chunk covering nothing is one that was created and never written to -- an
// append whose very first write failed. It says nothing, so it is not a chunk.
if (end == null || end <= first) null else Chunk(file, first, end, open)
}
.sortedBy { it.first }
}
/**
* The open chunk's end: its last line's seq plus one, or the in-memory end while this instance
* is the one writing it.
*
* An open chunk whose last line cannot be read is this app having died mid-write. That line is
* dropped and the file truncated to the last good one, which is the one place damage is
* repaired rather than discarded: the tail of an append-only file is the only place a partial
* line can be.
*/
private fun openEndOf(file: File, first: Long): Long {
if (openFile == file && openEnd > 0) return openEnd
repairTail(file)
var end = first
eachLineBackwards(file) { _, line ->
seqOf(line)?.let { end = it + 1 }
false
}
return end
}
/**
* The contiguous run of adjacent chunks ending at the newest one, oldest first.
*
* A newest chunk that is not raw cannot happen while this code is the only writer, and means
* the directory is not to be trusted -- so the session is discarded.
*/
private fun suffix(): List<Chunk> {
val all = chunks()
var index = all.size - 1
val newest = all.lastOrNull() ?: return emptyList()
if (newest.rows) throw Damaged(newest.file)
val run = ArrayDeque<Chunk>()
run.addFirst(newest)
while (index > 0 && all[index - 1].end == run.first().first) {
index--
run.addFirst(all[index])
}
return run.toList()
}
/**
* Each line of [chunk], newest first, until [take] says stop.
*
* Backwards and lazily, because every question this cache is asked is about the newest end and
* a live run grows to the size of the conversation. Reading the file whole to answer with
* eighty lines of it is the cost the server's own reader was rewritten to stop paying.
*
* Damage anywhere but at the tail of the open chunk was not written by this code, and there is
* no honest way to say what a chunk covers with a line of it unreadable -- so it discards the
* session rather than serving what it can read.
*/
private fun eachLine(chunk: Chunk, take: (String) -> Boolean) {
eachLineBackwards(chunk.file) { _, line ->
if (seqOf(line) == null) throw Damaged(chunk.file)
take(line)
}
}
// -- writing -----------------------------------------------------------------------------
/** The writer for the chunk [seq] belongs in, opening or rotating one as it has to. */
private fun writerFor(seq: Long): BufferedWriter? {
writer?.let { held ->
if (seq == openEnd) return held
if (seq < openEnd) return null
// A gap: what this instance has written covers up to `openEnd`, and that is the name
// the chunk gets before a new one starts at the arriving seq.
closeOpenChunk(openEnd)
}
dir.mkdirs()
// An open chunk left by an earlier instance, or by an earlier screen.
chunks()
.lastOrNull { it.open }
?.let { existing ->
if (seq < existing.end) return null
if (seq == existing.end) {
openFile = existing.file
openEnd = existing.end
return FileWriter(existing.file, true).buffered().also { writer = it }
}
rename(existing.file, existing.first, existing.end)
}
// A chunk that was created and never written to would otherwise be left behind under a name
// a second one is about to want; it covers nothing, so nothing is lost with it.
dir.listFiles().orEmpty().forEach {
if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L)
it.delete()
}
val file = File(dir, "$seq-open.raw.jsonl")
openFile = file
openEnd = seq
return FileWriter(file, false).buffered().also { writer = it }
}
/** Renames the open chunk to the range it turned out to cover, so it stops being open. */
private fun closeOpenChunk(end: Long) {
val file = openFile
closeWriter()
if (file == null) return
val first = CHUNK_NAME.matchEntire(file.name)?.groupValues?.get(1)?.toLongOrNull()
if (first != null) rename(file, first, end)
}
private fun rename(file: File, first: Long, end: Long) {
file.renameTo(File(dir, "$first-$end.raw.jsonl"))
}
private fun closeWriter() {
try {
writer?.close()
} catch (_: IOException) {
// Nothing left to do about it: the file is what it is, and the read path repairs a
// half-written tail.
}
writer = null
openFile = null
openEnd = 0
}
// -- failure -----------------------------------------------------------------------------
/** A chunk that cannot be read as what its name claims. */
private class Damaged(val file: File) : RuntimeException()
/**
* Runs [body], answering [ifBroken] when the directory cannot give a real answer.
*
* None of this is reported on screen: none of it changes what the screen shows -- every read
* here has a network path beside it producing the same result -- and the reader has nothing to
* do about it. Damage discards this session's cache, which makes the next open an ordinary cold
* one.
*/
private fun <T> guard(ifBroken: T, body: () -> T): T =
// A disk that refused once will refuse again, once per delta, so the first refusal is also
// the last: this instance stops writing rather than logging a line a token.
guardIo(
ifBroken,
warn,
onFailure = {
disabled = true
closeWriter()
},
) {
try {
body()
} catch (e: Damaged) {
warn("transcript cache damaged at ${e.file}; discarding ${dir.name}")
closeWriter()
dir.deleteRecursively()
ifBroken
}
}
}
/** `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is not ours. */
private val CHUNK_NAME = Regex("""^(\d+)-(\d+|open)\.(rows|raw)\.jsonl$""")
private val SEQ_IN_LINE = Regex(""""seq"\s*:\s*(\d+)""")
private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""")
/**
* One line's sequence number, or null when the line is not one of ours.
*
* A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM
* tests: the seq is the first field the server writes, so the first match is the top-level one.
*/
private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull()
/** Whether a line is one streamed piece of a reply, which is what makes a run of them one row. */
private fun isDelta(line: String): Boolean =
TYPE_IN_LINE.find(line)?.groupValues?.get(1) == "assistantText"
/**
* How much of a file is read at a time when walking it backwards. One block covers a page of a
* transcript comfortably, and the walk stops as soon as the caller has what it asked for.
*/
private const val READ_BLOCK = 64 * 1024
/**
* Calls [onLine] with each non-blank line of [file], **newest first**, along with the byte offset
* it starts at, until [onLine] answers false.
*
* Every question the cache is asked is about the newest end of a chunk, and a live run reaches the
* size of the conversation, so reading forwards means reading a transcript to answer with the last
* eighty lines of it.
*
* Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte
* UTF-8 sequence; each line is decoded whole. A missing file yields nothing.
*/
private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) {
if (!file.isFile) return
RandomAccessFile(file, "r").use { handle ->
// Bytes below `unread` have not been looked at; `pending` is the oldest line so far, which
// is incomplete until a newline is found before it in an older block.
var unread = handle.length()
var pending = ByteArray(0)
while (unread > 0) {
val take = minOf(READ_BLOCK.toLong(), unread).toInt()
val start = unread - take
val block = ByteArray(take)
handle.seek(start)
handle.readFully(block)
val buffer = if (pending.isEmpty()) block else block + pending
var lineEnd = buffer.size
var at = buffer.size - 1
while (at >= 0) {
if (buffer[at] == NEWLINE) {
val line = String(buffer, at + 1, lineEnd - at - 1, Charsets.UTF_8)
if (line.isNotBlank() && !onLine(start + at + 1, line)) return
lineEnd = at
}
at--
}
pending = buffer.copyOfRange(0, lineEnd)
unread = start
}
// The first line of a file has no newline before it to be found.
val first = String(pending, Charsets.UTF_8)
if (first.isNotBlank()) onLine(0, first)
}
}
private const val NEWLINE = '\n'.code.toByte()
/**
* Drops a final line that is not one of ours, by truncating the file to where it starts.
*
* This app having died mid-write is the one kind of damage that is repaired rather than discarded:
* the tail of an append-only file is the only place a partial line can be. A second bad line is not
* this, and is left for the read path to notice.
*/
private fun repairTail(file: File) {
var truncateTo = -1L
eachLineBackwards(file) { offset, line ->
if (seqOf(line) == null) truncateTo = offset
false
}
if (truncateTo >= 0) RandomAccessFile(file, "rw").use { it.setLength(truncateTo) }
}
private fun sizeOf(file: File): Long =
if (file.isDirectory) file.listFiles().orEmpty().sumOf { sizeOf(it) } else file.length()
/**
* The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance.
* [onFailure] is what the caller does about it beyond answering [ifBroken].
*/
private fun <T> guardIo(
ifBroken: T,
warn: (String) -> Unit,
onFailure: () -> Unit = {},
body: () -> T,
): T =
try {
body()
} catch (e: IOException) {
warn("transcript cache unusable: ${e.message}")
onFailure()
ifBroken
} catch (e: SecurityException) {
warn("transcript cache unreadable: ${e.message}")
onFailure()
ifBroken
}
@@ -5,9 +5,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
* stream is the only data source -- opening a session screen replays from seq 0, and a reconnect
* resumes from the last seq seen, so there is no separate history fetch to drift from it.
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]).
*
* Events are the only data source, and there is deliberately no second shape for history to drift
* from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are
* all the same events through the same parser. [TranscriptCache] stores the server's lines rather
* than these rows for exactly that reason -- a row is a rendering, and its shape changes whenever
* this file does.
*/
@Immutable
sealed class TranscriptItem {
@@ -19,10 +23,8 @@ sealed class TranscriptItem {
* list is addressed by position: whatever somebody had scrolled to keeps its index while the
* content underneath it slides, which reads as the view scrolling on its own.
*
* A seq is the right identity because it is what the transcript itself is ordered by, it never
* changes, and it is already carried by every event. A row built from several events -- a
* streaming message, a tool call and its result -- keeps the seq of the first, so it holds
* still while the rest of it arrives.
* A row built from several events keeps the seq of the first, so it holds still while the rest
* of it arrives.
*/
abstract val seq: Long
@@ -30,10 +32,8 @@ sealed class TranscriptItem {
* This item's identity on screen, which is its [seq] for everything that has one of its own.
*
* Here rather than in [TranscriptRow.Single] because the two items that need something else are
* the two that know why: a tool call is named after its run, and a peer note is *sorted* by the
* turn it started rather than by where it arrived. Asking each item what it is called is also
* what stops the next such item being missed -- a `when` over concrete types in the row would
* have to gain a case, silently, and nothing says when it did not.
* the two that know why. Asking each item what it is called is also what stops the next such
* item being missed -- a `when` over concrete types would have to gain a case, silently.
*/
open val key: Any
get() = seq
@@ -54,15 +54,13 @@ sealed class TranscriptItem {
* What it buys is the split. [transcriptUnits] keeps the newest reply whole because a
* streaming reply's text changes per delta and splitting a changing text is a parse per
* delta -- but "newest" outlives the turn, so a session that ends on a long reply was
* drawing it as one item indefinitely, with every node of it alive. Measured on a Pixel 9
* Pro XL: one 34,996px reply on screen put the frame's draw phase at 13.8ms, 79% of it the
* framework's own bookkeeping, which grows with alive nodes.
* drawing it as one item indefinitely. Measured on a Pixel 9 Pro XL: one 34,996px reply on
* screen put the frame's draw phase at 13.8ms, 79% of it framework bookkeeping.
*
* Folded from the status event that ended the turn, rather than read off the screen's
* Folded from the status event that ended the turn rather than read off the screen's
* status, because rows only change through the held-events gate: the split changes the
* newest row's list identity, and doing that from a status flip while somebody is reading
* inside that reply would step the list under them. An event has to wait for the reader to
* be at the newest end; a screen state does not.
* inside that reply would step the list under them.
*/
val settled: Boolean = false,
) : TranscriptItem()
@@ -74,11 +72,10 @@ sealed class TranscriptItem {
* The run of adjacent calls this one belongs to, named once when the call is folded in and
* never recomputed.
*
* Carried rather than derived because a run can gain members at *either* end -- a new call
* arriving beside it, or a page of history arriving in front of it -- so no function of its
* current members is stable. It is the first call's id at the moment the run started, which
* is a name rather than a description: [joinPages] hands it to older calls that turn out to
* belong to the same run, instead of renaming the run they joined.
* Carried rather than derived because a run can gain members at *either* end, so no
* function of its current members is stable. It is the first call's id at the moment the
* run started, which is a name rather than a description: [joinPages] hands it to older
* calls that turn out to belong to the same run.
*/
val runId: String,
val tool: String,
@@ -89,19 +86,16 @@ sealed class TranscriptItem {
* The questions this call is waiting on, in the order they were asked.
*
* On the call's own row rather than beside it: an ask used to arrive as a second card
* repeating the input verbatim, so the reader saw the same command twice and had to work
* out that it was one event. The backend says which call a question is about, so this is a
* fact rather than a match on the input.
* repeating the input verbatim, so the reader saw the same command twice. The backend says
* which call a question is about, so this is a fact rather than a match on the input.
*
* A list because AskUserQuestion asks up to four at once, and they are one decision to make
* -- a permission is the case of exactly one, not a different shape.
* A list because AskUserQuestion asks up to four at once, and a permission is the case of
* exactly one rather than a different shape.
*/
val asks: List<QuestionCard> = emptyList(),
/**
* Images this call's result carried, drawn under it.
*
* Beside it they had to be paired by position, and position is the thing a page boundary
* breaks -- a screenshot loaded on one page and its call on the next read as unrelated.
* Images this call's result carried, drawn under it. Beside it they had to be paired by
* position, and position is what a page boundary breaks.
*/
val images: List<String> = emptyList(),
) : TranscriptItem() {
@@ -129,9 +123,8 @@ sealed class TranscriptItem {
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
/**
* A message another agent sent this session.
*
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
* A message another agent sent this session. Its own row rather than a [UserMsg]: see
* [PeerMessageRow] for why the voice matters.
*/
data class PeerNote(
override val seq: Long,
@@ -140,11 +133,9 @@ sealed class TranscriptItem {
/**
* The seq of the event this note came in on, which is what makes it itself.
*
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began at
* so the note is drawn above the reply it caused. Two messages that arrive during one turn
* therefore share a seq -- and sharing an identity as well killed the app, because the
* transcript list refuses two items with one key. Two agents writing to a session mid-turn
* is an ordinary afternoon, not a corner.
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began
* at. Two messages that arrive during one turn therefore share a seq -- and sharing an
* identity as well killed the app, because the list refuses two items with one key.
*/
val arrived: Long = seq,
) : TranscriptItem() {
@@ -153,10 +144,9 @@ sealed class TranscriptItem {
}
/**
* A command the session ran on itself -- `/compact`, `/rename`.
*
* Kept in the transcript rather than only shown while it waits, because it explains what
* follows: a conversation that suddenly has half the context, or a session with a new name.
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
* than only shown while it waits, because it explains what follows: a conversation that
* suddenly has half the context, or a session with a new name.
*/
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
@@ -165,7 +155,6 @@ sealed class TranscriptItem {
/**
* A clear that happened: everything above it left the session's context and stayed on screen.
*
* Carries only its position, because that is all it means.
*/
data class ClearedNote(override val seq: Long) : TranscriptItem()
@@ -174,12 +163,12 @@ sealed class TranscriptItem {
* A compaction that happened, and what it recovered.
*
* In the transcript rather than only in the status line, because the status is gone the moment
* it finishes and this is the part worth keeping: it is the explanation for a gap in the
* conversation, and for a minute or two in which the session was busy with nothing to show.
* it finishes and this is the part worth keeping: the explanation for a gap in the
* conversation.
*
* The wire also says what triggered it, and this deliberately does not carry that: the row says
* the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would
* be a field nothing can read.
* The wire also says what triggered it, and this deliberately does not carry that -- the row
* says the two sizes and nothing else, so keeping the trigger would be a field nothing can
* read.
*/
data class CompactedNote(
override val seq: Long,
@@ -192,16 +181,13 @@ sealed class TranscriptItem {
* The run a call joins: the one it lands next to, or a new one named after itself.
*
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
* keeps whatever it was called when it started, however many calls arrive at either end of it
* afterwards.
* keeps whatever it was called when it started, however many calls arrive at either end afterwards.
*
* A question to the reader is in a run of its own, which is what puts it on the transcript as a row
* rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is
* always visible, since a run of one is drawn as itself rather than as a group; and the calls
* around it fall into a group before it and a group after it, so where the reader was asked
* something is legible in the shape of the transcript without opening anything. It ends the run
* before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in
* the work, not a gap in the middle of one run.
* rather than inside a collapsed "Called 6 tools" card. Two things follow: it is always visible,
* since a run of one is drawn as itself; and the calls around it fall into a group before it and a
* group after it, so where the reader was asked something is legible in the shape of the transcript
* without opening anything.
*/
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
@@ -214,31 +200,23 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
* boundary cut in two.
*
* Two things straddle a boundary: a tool call separated from its result, and a message separated
* from the rest of itself. Both were one thing before the transcript was cut into pages, and both
* have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn
* twice, arriving from the same cause.
* from the rest of itself. Both were one thing before the transcript was cut into pages.
*
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and
* concatenating the two lists left *both*: the same call twice, once as a proper card and once as a
* nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than
* the miscount -- the extra row is at the join, so it also moves everything the reader was looking
* at.
* concatenating the two lists left *both*: the same call twice.
*
* Merged by the call's own id rather than by position, because position is exactly what a page
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the
* newer on what an end knows (the output, and whether it finished), which is the only way round
* that loses nothing.
* boundary destroys. The older row wins on what a start knows and the newer on what an end knows,
* which is the only way round that loses nothing.
*
* The third thing is the *run*, and it is the one that used to be missed. Every page ends up here,
* but [adoptRun] only ran on the path where a split call had been found -- so the boundary that
* falls cleanly between two finished calls, which is most of them, went straight to concatenation
* and left the older page's calls under the run name they were folded with. On screen: one run of
* tool calls drawn as two groups, with the seam wherever the reader happened to have paged. The two
* early returns were an optimisation on a list the size of one page, and they were skipping work
* rather than saving it.
* falls cleanly between two finished calls, which is most of them, left the older page's calls
* under the run name they were folded with. On screen: one run of tool calls drawn as two groups,
* with the seam wherever the reader happened to have paged.
*/
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitMessage(earlier, later)
@@ -271,15 +249,13 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
/**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
*
* [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas
* accumulate into the message before them -- so two meeting at a join are always the two halves of
* one reply, and leaving them apart drew a single answer as two, with a paragraph break through the
* middle of a sentence.
* [foldEvent] never leaves two assistant messages next to each other inside one page, so two
* meeting at a join are always the two halves of one reply, and leaving them apart drew a single
* answer as two with a paragraph break through the middle of a sentence.
*
* The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on
* screen, and renaming that is how the list loses its anchor. It grows by what the older half
* brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded,
* so the growth extends off the top of the screen, away from the row the list anchors to.
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
* loaded, so the growth extends off the top of the screen.
*/
private fun healSplitMessage(
earlier: List<TranscriptItem>,
@@ -297,19 +273,18 @@ private fun healSplitMessage(
* Hands the older calls at the join the name of the run they are joining.
*
* The two pages were folded separately, so a run split by the boundary came back as two runs with
* two names. Naming the joined run after the *older* half would be the obvious way round and is the
* wrong one: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor and steps under them. So the arriving
* calls take the name of the ones already there, and nothing visible changes identity.
* two names. Naming the joined run after the *older* half would be the obvious way round and is
* wrong: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor.
*/
private fun adoptRun(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): List<TranscriptItem> {
val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier
// A question is in a run of its own on both sides of the join, the same as it would be had
// the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a
// group straight through the row the reader was asked something on.
// A question is in a run of its own on both sides of the join, the same as it would be had the
// two pages been folded as one. Without this the heal would merge a group straight through the
// row the reader was asked something on.
if (first.tool == ASK_USER_QUESTION) return earlier
val joining = first.runId
val tail = earlier.takeLastWhile {
@@ -324,10 +299,8 @@ private fun adoptRun(
* A peer message goes above the turn it started, not where it happened to arrive.
*
* The live Claude Code path cannot record it in place: the CLI says nothing about a peer message
* until the turn's `result`, so the event lands below the whole reply it caused -- the answer
* printed above the question. The server stamps it with where that turn began
* ([SessionEvent.PeerMessage.turnStart]) and the note takes that seq, so it sorts into the list
* where it belongs rather than being drawn out of order at the end.
* until the turn's `result`, so the event lands below the whole reply it caused. The server stamps
* it with where that turn began and the note takes that seq.
*
* Taking the turn's opening seq as its own is also what keeps the list sorted, which anchors and
* paging both depend on. It is only a *position*, though, and the note keeps its own arrival seq as
@@ -335,8 +308,7 @@ private fun adoptRun(
* seq belongs to a status change and a status draws no row -- true, and it answered the wrong
* question: what two notes stamped with the same turn collide with is each other.
*
* Without a stamp -- a message replayed out of a session file, which is already in the right place
* -- it stays where it arrived.
* Without a stamp -- a message replayed out of a session file -- it stays where it arrived.
*/
private fun placePeerNote(
items: List<TranscriptItem>,
@@ -355,15 +327,14 @@ private fun placePeerNote(
* The calls the note now sits in front of, renamed if they were sharing a run with the calls behind
* it.
*
* A run is named from what a call landed next to (see [runIdFor]), and nothing there knows about
* turns -- so a turn opening with a tool call, straight after one that ended with one, folds them
* into a single run. Left alone, [groupToolRuns] would flush at the note and hand both halves the
* same name: two rows with one key, which a keyed list cannot draw at all.
* A run is named from what a call landed next to, and nothing there knows about turns -- so a turn
* opening with a tool call, straight after one that ended with one, folds them into a single run.
* Left alone, [groupToolRuns] would flush at the note and hand both halves the same name: two rows
* with one key, which a keyed list cannot draw at all.
*
* The later half is the one renamed, which is the opposite of a page join ([adoptRun]) and right
* for the opposite reason. There the two halves were always one run and the newer was already on
* screen; here they were never one turn's work, and both halves change appearance at the same
* moment the note appears between them.
* for the opposite reason: there the two halves were always one run, here they were never one
* turn's work.
*/
private fun splitRun(tail: List<TranscriptItem>, behind: String?): List<TranscriptItem> {
val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail
@@ -401,12 +372,10 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
)
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd ->
// Created when its start is not here, rather than dropped. A
// fold that only ever *updates* loses the whole call when the
// start fell outside the loaded window, and a tool call that
// renders as nothing is indistinguishable from one that never
// happened. The name is unknown from an end alone; loading the
// page before this one replaces the row with the real thing.
// Created when its start is not here, rather than dropped. A fold that only ever
// *updates* loses the whole call when the start fell outside the loaded window, and a
// tool call that renders as nothing is indistinguishable from one that never happened.
// Loading the page before this one replaces the row with the real thing.
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
} else {
@@ -414,9 +383,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
TranscriptItem.ToolRun(
entry.seq,
event.id,
// The name is not known from an end alone, so a call that was an ask
// cannot be recognised as one here; loading the page before this
// replaces the row with the real thing, which is when it splits out.
// The name is not known from an end alone, so a call that was an ask cannot
// be recognised as one here; the page before this replaces the row.
runIdFor(items, event.id, "tool"),
"tool",
"",
@@ -435,9 +403,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
event.multiSelect,
emptyList(),
)
// A question with no tool behind it -- AskUserQuestion, or an ask
// whose call fell outside the loaded window -- is a card of its
// own, which is what every question was before this.
// A question with no tool behind it -- AskUserQuestion, or an ask whose call fell
// outside the loaded window -- is a card of its own.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
@@ -448,9 +415,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
}
}
is SessionEvent.Answered ->
// Resolved wherever it is drawn: a card of its own, or a tool
// row's ask. Missing the second left an Allow/Deny pair live on
// a question already answered from another device.
// Resolved wherever it is drawn: a card of its own, or a tool row's ask. Missing the
// second left an Allow/Deny pair live on a question already answered from another
// device.
items.map {
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
@@ -470,20 +437,19 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.CommandQueued -> items
// No row of its own: a message that is still waiting is drawn as a pending bubble below
// the transcript, and becomes an ordinary one where the session read it.
// No row of its own: a message that is still waiting is drawn as a pending bubble below the
// transcript, and becomes an ordinary one where the session read it.
is SessionEvent.MessageQueued -> items
// The bubble goes away and nothing takes its place: the message was never read, so there
// is nothing it belongs above.
// The bubble goes away and nothing takes its place: the message was never read, so there is
// nothing it belongs above.
is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items
is SessionEvent.Status -> settleReply(items, event.state)
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of
// its own when there is not -- a person's own attachment belongs
// to no call, and neither does one whose call fell outside the
// loaded window.
// Under the call that produced it when there is one, and a row of its own when there is
// not -- a person's own attachment belongs to no call, and neither does one whose call
// fell outside the loaded window.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
@@ -503,9 +469,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
/**
* A status saying the session stopped working is the moment its newest reply is finished.
*
* See [TranscriptItem.AssistantMsg.settled] for what the mark buys and why it is made here in the
* fold. Status changes are transcript events with seqs of their own, so a replayed session settles
* its replies the same way a live one does.
* See [TranscriptItem.AssistantMsg.settled]. Status changes are transcript events with seqs of
* their own, so a replayed session settles its replies the same way a live one does.
*/
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
if (sessionWorking(state)) return items
@@ -528,8 +493,7 @@ private fun updateTool(
* The default dispatcher sizes itself to the machine, which is right for work somebody is waiting
* on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and
* taking every core for them leaves the thread that draws the frame queueing behind one -- measured
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile, which is the frame failing to
* *start* rather than taking too long once it had.
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile.
*/
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
@@ -538,37 +502,33 @@ private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
* Parses the markdown among [rows], off whatever thread is drawing.
*
* Called where a page of transcript is folded rather than where a row is composed, which is the
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
* [ParsedReplies].
* whole point: the work happens seconds before the reader reaches the rows it was done for.
*
* What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer
* message, every one of them whole, since every piece of a message is drawn from its one parse --
* because a string warmed under a key no row ever looks up is a miss that nothing reports; see
* [transcriptUnits], which is the flatten this has to agree with. It reads the same
* message -- because a string warmed under a key no row ever looks up is a miss that nothing
* reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same
* [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages
* hand it back through here, while the whole loaded transcript crosses this on every page.
* hand it back through here.
*
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
* message was missing: this used to filter for assistant replies alone, so the one row type nobody
* had thought about paid its whole parse in the frame it appeared in, with no counter saying which
* row it was.
* had thought about paid its whole parse in the frame it appeared in.
*/
suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
withContext(parsingThreads) {
val texts = rows.flatMap { row ->
when (row) {
is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
// A message from another agent is markdown too, and it is the longest thing
// in a transcript often enough that leaving it out was the whole of why one
// cost a fifth of a second to open: it was the only markdown in the app
// parsed on the thread that draws.
// A message from another agent is markdown too, and it is the longest thing in a
// transcript often enough that leaving it out was the whole of why one cost a fifth
// of a second to open.
is TranscriptItem.PeerNote -> listOf(row.text)
else -> emptyList()
}
}
if (texts.isNotEmpty()) replies.warm(texts)
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's
// licence to draw these as blocks on the composing thread.
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's licence
// to draw these as blocks on the composing thread.
rows.forEach { if (it is TranscriptItem.AssistantMsg) replies.markSplitReady(it.text) }
}
}
@@ -25,31 +25,24 @@ import androidx.compose.ui.unit.dp
* zero is the newest content and sits at the bottom, so a message arriving extends the end the
* viewport is pinned to and following it is not an effect -- and a page of older history lands at
* indices past everything visible, which moves nothing on screen. The keyboard is the same case
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge. A
* conversation shorter than the screen stacks from the bottom, hanging from the composer.
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge.
*
* The lazy list is also the whole of the windowing. Only what is near the viewport is composed and
* alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the
* property a plain column here had to approximate with retained ranges and stand-in spacers, each
* of which was a way to flicker. An item the framework composes is drawn the same frame it is
* placed, and an item off screen is not a node at all.
* of which was a way to flicker.
*
* What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a
* reply, and its parse is already made by [warm] before the fold that introduces it -- so entering
* composition costs laying out one paragraph, not parsing a message.
* reply, and its parse is already made by [warm] before the fold that introduces it.
*
* The whole list sits in a [SelectionContainer], which is what makes every word in the transcript
* selectable by the platform's own press-and-hold. Here rather than at each place text is drawn: a
* transcript is one body of text to a reader, and a container per row would mean a selection could
* never cross from a reply into the tool output that follows it -- and would leave whatever was
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep
* their tap handlers: selection is a long press, and the container passes an ordinary click through
* to the card under it.
* The whole list sits in a [SelectionContainer], which is what makes every word selectable by the
* platform's own press-and-hold. Here rather than at each place text is drawn: a transcript is one
* body of text to a reader, and a container per row would mean a selection could never cross from a
* reply into the tool output that follows it -- and would leave whatever was drawn without one
* silently unselectable. Rows keep their tap handlers: selection is a long press.
*
* [selection] is the container's own state, held by the caller rather than made here, because the
* rows have to be able to ask whether anything is selected before they act on a tap -- a tap whose
* job is to put a selection away is not also a tap on the card under it. See the caller's
* `expanding`.
* rows have to be able to ask whether anything is selected before they act on a tap.
*/
@Composable
fun TranscriptList(
@@ -69,8 +62,7 @@ fun TranscriptList(
modifier =
// Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which
// half;
// see [drawAccounting]. Measure includes composing the items that scrolled in.
// half. Measure includes composing the items that scrolled in.
modifier
.layout { measurable, constraints ->
val started = System.nanoTime()
@@ -104,8 +96,7 @@ fun TranscriptList(
}
// Standing in for everything not fetched yet. Only here while there is more -- its
// appearance at the top edge is also roughly when the next page is asked for, so what
// it
// reports is a fetch in flight rather than an end reached.
// it reports is a fetch in flight rather than an end reached.
if (moreHistory) {
item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
@@ -0,0 +1,177 @@
package com.example.aiapp
import android.content.Context
import java.io.File
import java.util.concurrent.atomic.AtomicReference
/**
* Where the session screen gets a transcript from: this phone's copy first, the server for the
* rest.
*
* One seam rather than a cache the screen has to remember to consult. Everything it fetched before
* is asked of this, and everything the server sends is written into the cache on the way past, so
* the screen never learns which side answered. What it does learn, through [DebugStats], is how
* often each one did.
*
* See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind: the cache is never load-bearing.
* Every read has a network path beside it producing the same result.
*/
class TranscriptSource(
private val settings: ServerSettings,
private val sessionId: String,
val cache: SessionCache,
) {
private val stream = AtomicReference<EventStream?>(null)
/**
* The cached opening window, or null when there is nothing usable to draw.
*
* Drawn *before* [probe] returns, which is the whole point of the feature: the rows are on
* screen while the check that they are still the server's rows is in flight, and a failed check
* replaces them exactly as a `reset` does.
*/
fun cachedOpening(limit: Int = OPENING_WINDOW): List<SeqEvent>? {
if (cache.tail() == null) return null
val lines = cache.newest(limit)
if (lines.isEmpty()) return null
return try {
lines.map { parseSeqEvent(it) }
} catch (e: org.json.JSONException) {
// Lines this build cannot read at all, which the cache's own checks cannot see: it
// reads a seq off a line, not an event. Nothing to serve, so a cold open.
cache.purge()
null
}
}
/**
* Whether the server's event at the cached cursor is still the cached one.
*
* The screen must not resume a stream from a cached seq unless it is the same conversation. A
* transcript is append-only in ordinary use, but the file can be replaced or truncated -- a
* sandbox re-seeded with the same ids, a backup restored, a session re-imported -- and the
* server's catch-up on such a file would hand this phone a continuation of a *different*
* conversation, spliced onto the cached one with no seam. Caught with one request of a few
* hundred bytes, in the slot the opening page's request used to be in.
*
* False purges the cache and means "open cold". A throw is the server not being askable, which
* is neither: the cached rows stay on screen and the caller tries again on the reconnect
* schedule.
*
* What this cannot see is a line changed in the middle of the file with the tail intact. That
* is what the Reload button in session settings is for.
*/
suspend fun probe(): Boolean {
val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
// the cursor when the server still has one there.
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1)
val matches =
answer.size == 1 &&
try {
answer[0].second == parseSeqEvent(tail.line)
} catch (e: org.json.JSONException) {
false
}
if (!matches) cache.purge()
return matches
}
/**
* Today's opening fetch, kept as the start of the live run. Only called when the cache has
* nothing to open with, or when [probe] said what it had was not the server's.
*/
suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush()
return page.map { it.second }
}
/**
* The page before [before]: from the cache when it holds it, otherwise from the server bounded
* by what the cache already has.
*
* The bound is what keeps the cache worth having. A coalesced page reaches back as far as its
* row count takes it -- a single reply is hundreds of lines -- so a page fetched after the
* reader has been away would run straight past the cached run and overlap it, and an
* overlapping page cannot be stored. Told where this phone's copy starts, the server stops
* there instead.
*/
suspend fun page(before: Long, limit: Int, coalesce: Boolean): List<SeqEvent> {
cache.page(before, limit, rows = coalesce)?.let { lines ->
DebugStats.count("transcript page from cache")
return lines.map { parseSeqEvent(it) }
}
DebugStats.count("transcript page from server")
val page =
fetchTranscript(
settings,
sessionId,
before = before,
limit = limit,
coalesce = coalesce,
after = cache.coveredUpTo(before)?.minus(1),
)
if (page.isNotEmpty()) {
// `before` rather than the newest line's seq: a coalesced page covers everything up to
// the cursor it was asked with, and nothing in its lines says so.
cache.storePage(page.map { it.first }, page.first().second.seq, before, rows = coalesce)
}
return page.map { it.second }
}
/**
* [EventStream.run], with every frame written to the cache before [onEvent] sees it.
*
* Before, so that an event held back for a reader who is scrolled away is already on disk --
* what the cache holds is what the server sent, not what the screen has got round to drawing.
* Flushed on each status change, which is a turn's boundary and the granularity a crash may as
* well lose.
*/
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, sessionId)
stream.getAndSet(opened)?.close()
try {
opened.run(after, onOpen, onReset) { raw, entry ->
cache.append(raw, entry.seq)
if (entry.event is SessionEvent.Status) cache.flush()
onEvent(entry)
}
} finally {
cache.flush()
}
}
/** Ends the stream, from any thread, and leaves the cache with everything it was given. */
fun close() {
stream.getAndSet(null)?.close()
cache.flush()
}
}
/**
* How many events the screen opens with, cached or fetched.
*
* The server's own default for a page, named here because the cached opening has to be the same
* size as the fetched one -- a reader must not get a shorter first screen for having been here
* before.
*/
private const val OPENING_WINDOW = 80
/**
* Where this server's cached transcripts live.
*
* Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the
* server, which Android may delete under storage pressure without asking. Keyed by host and port
* because two servers can hold a session with the same id, and a line from one shown against the
* other is the whole invariant broken. `v1` is the layout's version.
*/
fun cacheRoot(context: Context, settings: ServerSettings): File {
val transcripts = File(context.cacheDir, "transcripts")
transcripts.listFiles()?.forEach { if (it.name != CACHE_VERSION) it.deleteRecursively() }
return File(transcripts, "$CACHE_VERSION/${settings.host}_${settings.port}")
}
private const val CACHE_VERSION = "v1"
@@ -12,8 +12,7 @@ import androidx.compose.ui.unit.dp
* at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be
* twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when
* the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst
* frame is bounded. This is the piece that was missing when a lazy list was last tried here; the
* block splitting existed only inside the row, where the list could not see it.
* frame is bounded. This is the piece that was missing when a lazy list was last tried here.
*
* Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit
* points back at its row. The list draws units; anchors and paging still speak seq.
@@ -27,10 +26,9 @@ sealed class TranscriptUnit {
abstract val seq: Long
/**
* This unit's position within its row, counted from the row's oldest end.
*
* What a saved scroll position carries besides the seq: a reply split into forty blocks needs
* more than "somewhere in this row" to put a reader back where they stopped.
* This unit's position within its row, counted from the row's oldest end. What a saved scroll
* position carries besides the seq: a reply split into forty blocks needs more than "somewhere
* in this row" to put a reader back where they stopped.
*/
abstract val ordinal: Int
@@ -66,15 +64,13 @@ sealed class TranscriptUnit {
*
* A peer message is the one row whose *opened* size is unbounded -- these are the longest
* things a transcript holds -- so it is flattened the same way a settled reply is, and for the
* same reason: as one item, every block of it is composed, measured, placed and kept alive
* while any part of it is on screen. Measured on the emulator, opening a 43KB one took the
* transcript's share of the draw phase from 0.81ms a frame to 3.85ms, and the framework's own
* per-frame bookkeeping -- which grows with how many nodes are *alive* -- from 0.39ms to
* 3.15ms.
* same reason. Measured on the emulator, opening a 43KB one took the transcript's share of the
* draw phase from 0.81ms a frame to 3.85ms, and the framework's own per-frame bookkeeping from
* 0.39ms to 3.15ms.
*
* The card is drawn in pieces rather than given up: a filled Material card is elevation zero,
* so it has no shadow to break, and each piece paints the same fill with only the corners it
* owns. See [PeerHeadRow] and [PeerBlockRow].
* owns.
*/
data class PeerHead(
override val seq: Long,
@@ -84,8 +80,7 @@ sealed class TranscriptUnit {
) : TranscriptUnit() {
/**
* The note's own key, so opening and shutting does not change what the list is anchored on
* -- and so two notes stamped with one turn's seq are still two items. See
* [TranscriptItem.PeerNote].
* -- and so two notes stamped with one turn's seq are still two items.
*/
override val key: Any
get() = item.key
@@ -119,8 +114,7 @@ sealed class TranscriptUnit {
*
* A user message is plain text, so cutting it costs a scan rather than a parse -- but the
* reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand
* pixels of `Text` whose layout lands in the frame the row scrolls into. Measured as the
* `measure: the whole transcript ... 112.1ms worst` in an otherwise smooth report.
* pixels of `Text` whose layout lands in the frame the row scrolls into.
*/
data class UserChunk(
override val seq: Long,
@@ -152,14 +146,11 @@ sealed class TranscriptUnit {
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start.
*
* Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is
* only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are,
* which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting.
* The reply still arriving -- the newest row, until the status event that ends its turn marks it
* [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and
* splitting it here would parse the whole message per delta on whichever thread is composing.
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
* live message a layer per piece. Once settled it splits like every other reply, which is what
* Every settled reply is cut into its pieces (via the caches on [replies] so a message is only ever
* cut once), and so is an *opened* peer message -- [openNotes] is which ones those are. A shut one
* is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text
* changes with every delta, and splitting it here would parse the whole message per delta on
* whichever thread is composing. Once settled it splits like every other reply, which is what
* bounds the newest row's cost after a session ends on a long one.
*
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
@@ -200,8 +191,8 @@ fun transcriptUnits(
}
}
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like
// the markdown splits so the scan too happens once per message, not once per fold.
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like the
// markdown splits so the scan happens once per message rather than once per fold.
val chunks = replies.chunksOf(item.text)
chunks.forEachIndexed { at, chunk ->
units +=
@@ -252,8 +243,8 @@ fun transcriptUnits(
}
units.reverse()
reportDuplicateKeys(units)
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy"
// is this number growing, and it was invisible until it was written down.
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy" is
// this number growing, and it was invisible until it was written down.
DebugStats.record("units flattened", System.nanoTime() - started)
return units
}
@@ -261,9 +252,8 @@ fun transcriptUnits(
/**
* Whether this reply should be drawn as blocks: settled, or anywhere but the newest row.
*
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two
* questions are separate because they are answered by different things: this one by the fold, the
* other by whether [warm] has run for the text. [unwarmedReplies] is the gap between them.
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are
* answered by different things: this one by the fold, the other by whether [warm] has run.
*/
private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) =
item.settled || index != lastIndex
@@ -272,8 +262,7 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex
* The replies among [rows] that should draw as blocks but whose parses are not made yet.
*
* Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold
* is the reply that just finished streaming -- nothing warms live deltas, so at the moment its turn
* ends its split would cost a whole-message parse on the composing thread. The session screen warms
* is the reply that just finished streaming -- nothing warms live deltas. The session screen warms
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
* ready parses.
*/
@@ -12,19 +12,15 @@ import androidx.compose.runtime.Composable
* on the main thread -- so it is not an error the screen can show, it closes the app. That is a
* disproportionate answer to a list with a repeat in it, and it lands on the reader rather than on
* whoever produced the repeat: on 2026-08-31 the import list crashed on a Claude Code session id
* recorded under two project directories, which is an ordinary state of a machine and not something
* the phone did.
* recorded under two project directories, which is an ordinary state of a machine.
*
* Every list in this app keyed on an id keyed it on an id *the server chose*, so all of them shared
* the hazard and none of them could rule it out locally. Hence one function they all go through
* rather than a `distinctBy` remembered at each call site.
* the hazard and none could rule it out locally. Hence one function they all go through.
*
* Dropping the repeat is the right answer here because the key is the whole identity: two rows with
* one id are two rows every action would treat as the same thing, so there is nothing to show about
* the second that the first is not already showing. Where the duplicate means something -- the
* import list's did -- the fix belongs at the source, and this is only what stops a data problem
* from being a crash. It is counted so the render report says it happened rather than leaving a
* silently shorter list.
* Dropping the repeat is right here because the key is the whole identity: two rows with one id are
* two rows every action would treat as the same thing. Where the duplicate means something, the fix
* belongs at the source, and this is only what stops a data problem from being a crash. It is
* counted so the render report says it happened rather than leaving a silently shorter list.
*
* The transcript's own list is deliberately not on this: its keys are made here rather than
* received, and it is the one list where an extra pass over the items is measurable.
@@ -27,17 +27,14 @@ import java.time.OffsetDateTime
* A dialog rather than a screen. Usage is something you check *against* what you were reading --
* "can I start this" is asked with the transcript still on screen -- and pushing a whole screen for
* it took the session away to answer a question about the session. It also has no navigation of its
* own: there is nothing here to open, so the only thing its Back could ever have meant was "put
* this away", which is what dismissing does. The system back gesture dismisses it, since a `Dialog`
* handles that itself.
* own, so the only thing its Back could ever have meant was "put this away".
*/
@Composable
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the
// gaps between its title, its content and its buttons at sizes meant for a sentence of prose
// and a decision; this is a dense read-out, and those gaps left a band of empty dialog above
// Close that was taller than a bar. Everything else here is what AlertDialog would have
// drawn -- the same container colour, the same corner -- so nothing about it looks foreign.
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
// taller than a bar.
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
@@ -49,11 +46,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
modifier = Modifier.fillMaxWidth(),
) {
// Deliberately not subtitled with the provider this was opened from. These
// numbers belong to an account on a particular machine, reported by whichever
// paid service answered there -- naming the session's provider here made an
// echo session's screen read "echo" above a line reading "claude", which is a
// claim about echo that nothing measured. Each machine names itself and the
// service it came from, which is the true scope.
// numbers belong to an account on a particular machine -- naming the session's
// provider here made an echo session's screen read "echo" above a line reading
// "claude". Each machine names itself and the service it came from.
Text(
"Usage",
style = MaterialTheme.typography.headlineSmall,
@@ -69,10 +64,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
}
}
Spacer(Modifier.height(8.dp))
// Scrolls rather than being trimmed: a machine can report any number of windows
// and there can be any number of machines, and a dialog is the one place where
// running out of room is silent. `fill = false` so a short read-out keeps a short
// dialog instead of stretching to the window.
// Scrolls rather than being trimmed: a machine can report any number of windows and
// there can be any number of machines, and a dialog is the one place where running
// out of room is silent. `fill = false` so a short read-out keeps a short dialog.
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
UsageBody(feed.snapshots)
}
@@ -93,8 +87,8 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
if (current.value.isEmpty()) {
// Not an error and not a blank screen: no machine offers a paid service,
// so there is genuinely nothing to report and saying so is the answer.
// Not an error and not a blank screen: no machine offers a paid service, so
// there is genuinely nothing to report and saying so is the answer.
Text(
"No machine here runs anything with usage limits.",
style = MaterialTheme.typography.bodyMedium,
@@ -103,17 +97,16 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
} else {
// No card around each machine. A card is a step up the surface ladder, and
// inside a dialog -- itself a raised surface -- the step barely renders while
// costing 16dp of padding on every side. What separates one machine from the
// next is the line naming it, which is enough for a list this short.
// costing 16dp on every side. What separates one machine from the next is the
// line naming it.
current.value.forEachIndexed { index, snapshot ->
if (index > 0) {
Spacer(Modifier.height(20.dp))
}
// Machine and service on one line: which account these numbers belong to
// is decided by both together, and stacked as a heading over a subtitle
// they read as a section of their own rather than as the label they are.
// Small and quiet, because the numbers below are what somebody opened
// this to see.
// Machine and service on one line: which account these numbers belong to is
// decided by both together, and stacked as a heading over a subtitle they
// read as a section of their own. Small and quiet, because the numbers
// below are what somebody opened this to see.
Text(
"${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
style = MaterialTheme.typography.bodySmall,
@@ -121,8 +114,8 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
)
SnapshotState(snapshot)
snapshot.windows.forEachIndexed { windowIndex, window ->
// Between the bars, not after the last one: a trailing gap here is
// what put a band of empty dialog above the Close button.
// Between the bars, not after the last one: a trailing gap here is what
// put a band of empty dialog above the Close button.
if (windowIndex > 0) {
Spacer(Modifier.height(12.dp))
}
@@ -139,8 +132,7 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
*
* The distinction the old single message could not draw. A machine nobody has logged in on is
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
* the interface nagging about a decision already made, and would dilute the marks that do mean
* something. Only the two faults are coloured as faults.
* the interface nagging about a decision already made. Only the two faults are coloured as faults.
*/
@Composable
private fun SnapshotState(snapshot: UsageSnapshot) {
@@ -152,8 +144,8 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Reached but refused, versus never reached at all: different things to go and do,
// so they say different things rather than sharing one "unavailable".
// Reached but refused, versus never reached at all: different things to go and do, so they
// say different things rather than sharing one "unavailable".
"failed" ->
Text(
snapshot.detail ?: "Couldn't read the limits from this machine.",
@@ -200,10 +192,9 @@ private fun WindowBar(window: UsageWindow) {
/**
* "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing.
*
* Null for a window that is not running, which is the case this row has always drawn as nothing and
* is right to: there is no end to report. What it used to get wrong is the other missing case, a
* timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared
* as an ISO string in the middle of a sentence written for a person. Both cases are named in
* Null for a window that is not running: there is no end to report. What this used to get wrong is
* the other missing case, a timestamp that arrived and could not be read -- printed raw, so a parse
* failure appeared as an ISO string in a sentence written for a person. Both are named in
* [WindowEnd], and the session bar words them the same way.
*/
private fun resetLine(window: UsageWindow): String? =
Binary file not shown.
@@ -0,0 +1,87 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The line arithmetic behind the file viewer.
*
* Worth a test rather than an eye: a line number that is one out is invisible in a short file and
* obvious in a long one, and a colour that stops at a line break is invisible until the file has a
* block comment in it.
*/
class FileLinesTest {
@Test
fun `a file that ends with a newline has the number of lines its author would count`() {
assertEquals(listOf("one", "two"), FileLines.of("one\ntwo\n", null).lines)
assertEquals(listOf("one", "two"), FileLines.of("one\ntwo", null).lines)
// Only one is dropped: a blank line at the end of a file is a line somebody typed.
assertEquals(listOf("one", "two", ""), FileLines.of("one\ntwo\n\n", null).lines)
}
@Test
fun `an empty file is one empty line`() {
val lines = FileLines.of("", null)
assertEquals(1, lines.size)
assertEquals("", lines.line(0).text)
}
@Test
fun `a comment that spans lines is coloured on every line it covers`() {
val text = "fn a() {}\n/* still\n a comment */\nfn b() {}\n"
val lines = FileLines.of(text, Language.RUST)
assertEquals(4, lines.size)
val comment = catppuccinSyntax().of(Kind.COMMENT)
// The whole of the middle line, and the part of the third up to the closer.
assertTrue(lines.line(1).spanStyles.any { it.item.color == comment && it.start == 0 })
val third = lines.line(2)
assertTrue(third.spanStyles.any { it.item.color == comment && it.end == third.length })
// And the code around it is not commented.
assertTrue(lines.line(0).spanStyles.none { it.item.color == comment })
assertTrue(lines.line(3).spanStyles.none { it.item.color == comment })
}
@Test
fun `a span never runs past the line it was cut into`() {
val lines = FileLines.of("val x = \"a\nb\"\nval y = 1\n", Language.KOTLIN)
for (index in 0 until lines.size) {
val line = lines.line(index)
assertTrue(
line.spanStyles.all { it.start >= 0 && it.end <= line.length },
"line $index",
)
}
}
/**
* The number every row in the viewer is sized to. It has to be the widest line, because rows of
* their natural widths scroll sideways by different amounts -- see [FileViewer].
*/
@Test
fun `the column count is the widest line, counting a tab as eight`() {
assertEquals(5, FileLines.of("one\nthree\nx\n", null).columns)
// A tab counts up to eight, and upwards on purpose: over-estimating leaves empty space
// past the longest line, under-estimating puts its end out of reach.
assertEquals(9, FileLines.of("\tx\nshort\n", null).columns)
// An empty file is one empty line, which is no columns at all rather than an error.
assertEquals(0, FileLines.of("", null).columns)
}
@Test
fun `a file with no language is plain`() {
val lines = FileLines.of("fn main() {}\n", null)
assertTrue(lines.line(0).spanStyles.isEmpty())
}
@Test
fun `a language comes from the extension, and only from a real one`() {
assertEquals(Language.KOTLIN, fileLanguage("Main.kt"))
assertEquals(Language.KOTLIN, fileLanguage("build.gradle.kts"))
assertEquals(Language.RUST, fileLanguage("files.rs"))
assertEquals(Language.TOML, fileLanguage("Cargo.toml"))
assertEquals(null, fileLanguage("Makefile"))
assertEquals(null, fileLanguage(".bashrc"))
assertEquals(null, fileLanguage("notes.txt"))
}
}
@@ -16,9 +16,7 @@ import kotlin.test.assertTrue
class HighlighterTest {
/** Every span of [kind] in [code], as the text it covers. */
private fun spans(code: String, language: Language, kind: Kind): List<String> =
scan(code, rulesOf(language))
.filter { it.kind == kind }
.map { code.substring(it.start, it.end) }
spansOf(code, language).filter { it.kind == kind }.map { code.substring(it.start, it.end) }
private fun assertSpans(
code: String,
@@ -182,6 +180,158 @@ class HighlighterTest {
assertSpans(code, Language.RON, Kind.LITERAL, "3")
}
// Markdown, which has a scanner of its own: what a character means there is decided by where
// it sits rather than by what it is, so most of these are about the cases where it means
// nothing at all.
@Test
fun `a heading is coloured whole and a hash inside a word is not one`() {
val code = "## Layout\nissue #12 is fixed\n#hashtag"
assertSpans(code, Language.MARKDOWN, Kind.KEYWORD, "## Layout")
}
@Test
fun `seven hashes are not a heading`() {
assertSpans("####### deep", Language.MARKDOWN, Kind.KEYWORD)
}
@Test
fun `a fence carries its language as metadata and its body as one string`() {
val code = "text\n```kotlin\nval x = 1\n```\nmore"
assertSpans(code, Language.MARKDOWN, Kind.METADATA, "kotlin")
assertSpans(code, Language.MARKDOWN, Kind.STRING, "```", "val x = 1", "```")
}
/** The state that crosses a line, so the one worth asking about at both ends. */
@Test
fun `a longer fence is not closed by a shorter one, and a heading inside it is not a heading`() {
val code = "````\n```\n# not a heading\n````\nafter"
assertSpans(code, Language.MARKDOWN, Kind.KEYWORD)
assertSpans(code, Language.MARKDOWN, Kind.STRING, "````", "```", "# not a heading", "````")
}
@Test
fun `an unclosed fence runs to the end rather than throwing`() {
assertSpans("```\nstill going", Language.MARKDOWN, Kind.STRING, "```", "still going")
}
@Test
fun `list markers and quote markers colour without their text`() {
val code = "- one\n2. two\n> quoted"
assertSpans(code, Language.MARKDOWN, Kind.MARK, "-", "2.", ">")
}
@Test
fun `a rule and a setext underline are the same mark`() {
assertSpans("Title\n=====\n\n---", Language.MARKDOWN, Kind.MARK, "=====", "---")
}
@Test
fun `emphasis needs something on both sides of it`() {
assertSpans("**bold** and *thin*", Language.MARKDOWN, Kind.LITERAL, "**bold**", "*thin*")
// The case the guards exist for: a C fragment written in a paragraph.
assertSpans("a * b * c and *p = *q", Language.MARKDOWN, Kind.LITERAL)
}
@Test
fun `an underscore inside a word emphasises nothing`() {
assertSpans("snake_case_name and _real_", Language.MARKDOWN, Kind.LITERAL, "_real_")
}
@Test
fun `a code span holds a backtick when opened with two`() {
assertSpans("``a ` b`` and `c`", Language.MARKDOWN, Kind.STRING, "``a ` b``", "`c`")
}
@Test
fun `an unclosed code span is ordinary text`() {
assertSpans("a ` b", Language.MARKDOWN, Kind.STRING)
}
@Test
fun `a link marks its brackets and colours its destination`() {
val code = "see [the plan](PLAN.md) now"
assertSpans(code, Language.MARKDOWN, Kind.MARK, "[", "]")
assertSpans(code, Language.MARKDOWN, Kind.METADATA, "(PLAN.md)")
}
@Test
fun `a table is found by its delimiter row, and pipes elsewhere are plain`() {
val code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph"
assertSpans(
code,
Language.MARKDOWN,
Kind.MARK,
"|",
"|",
"|",
"|---|---|",
"|",
"|",
"|",
)
}
@Test
fun `a table without outer pipes still colours, and the table ends with the rows`() {
val code = "a | b\n--- | ---\nnot a row"
assertSpans(code, Language.MARKDOWN, Kind.MARK, "|", "--- | ---")
}
/**
* The tag in the last case is not an autolink and is left plain, but the address inside it is
* still an address and the bare-URL pass finds it. That is the intended reading: raw HTML is
* not something this scanner knows, and a URL is a URL wherever it was written.
*/
@Test
fun `an autolink colours and an HTML tag does not`() {
val code = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">"
assertSpans(
code,
Language.MARKDOWN,
Kind.METADATA,
"<https://example.com>",
"<a@b.com>",
"http://x",
)
}
@Test
fun `a bare URL gives back the sentence's punctuation`() {
assertSpans(
"see https://example.com/a., and ssh://host/x)",
Language.MARKDOWN,
Kind.METADATA,
"https://example.com/a",
"ssh://host/x",
)
}
@Test
fun `a bracket a URL opened itself stays in it`() {
assertSpans(
"https://en.wikipedia.org/wiki/A_(b) here",
Language.MARKDOWN,
Kind.METADATA,
"https://en.wikipedia.org/wiki/A_(b)",
)
}
@Test
fun `a URL inside a link destination is not coloured twice`() {
assertSpans(
"[x](https://example.com)",
Language.MARKDOWN,
Kind.METADATA,
"(https://example.com)",
)
}
@Test
fun `a bracket with no destination after it is left plain`() {
assertSpans("an [aside] here", Language.MARKDOWN, Kind.MARK)
}
@Test
fun `an unknown fence language is drawn plain`() {
assertEquals(null, fenceLanguage("brainfuck"))
@@ -189,8 +339,8 @@ class HighlighterTest {
}
@Test
fun `every alias the fence table knows has rules`() {
Language.entries.forEach { rulesOf(it) }
fun `every language the fence table knows has a scanner`() {
Language.entries.forEach { spansOf("x", it) }
}
/**
@@ -218,11 +368,26 @@ class HighlighterTest {
"0x",
"1.2.3",
"a#b//c/*d*/'e\"f",
"```",
"*",
"**",
"~~",
"> ",
"- ",
"1.",
"[x](",
"#######",
"|",
"|---|",
"<",
"<>",
"http://",
"a://",
"\n\n \n",
)
for (language in Language.entries) {
for (code in nasty) {
val spans = scan(code, rulesOf(language))
val spans = spansOf(code, language)
spans.forEach {
assertTrue(
it.start in 0..it.end && it.end <= code.length,
@@ -0,0 +1,310 @@
package com.example.aiapp
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.junit.jupiter.api.io.TempDir
/**
* The cache's file logic, which is the half of the transcript cache that can be wrong without
* anything on screen saying so: a page served short, a chunk served across a gap, or a run of lines
* whose recorded coverage does not match what is in it.
*
* Lines here are the shape the server writes -- `{"seq":N,"ts":T,"type":...}` -- because that is
* what the cache reads its two facts off. Nothing parses JSON on either side.
*/
class TranscriptCacheTest {
@field:TempDir lateinit var temp: File
private val said = mutableListOf<String>()
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
private fun session(id: String = "s") = cache().session(id)
private fun line(seq: Long, type: String = "toolStart") =
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
private fun delta(seq: Long) = line(seq, "assistantText")
private fun dirOf(id: String = "s") = File(temp, "v1/host_8443/$id")
private fun names(id: String = "s") = dirOf(id).list().orEmpty().sorted()
private fun write(name: String, lines: List<String>, id: String = "s") {
dirOf(id).mkdirs()
File(dirOf(id), name).writeText(lines.joinToString("\n", postfix = "\n"))
}
private fun seqs(lines: List<String>?) = lines?.map {
Regex("\"seq\":(\\d+)").find(it)!!.groupValues[1].toLong()
}
@Test
fun an_appended_run_is_one_open_chunk_and_its_newest_line_is_the_tail() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
cache.flush()
assertEquals(listOf("1-open.raw.jsonl"), names())
assertEquals(CachedTail(3, line(3)), cache.tail())
assertEquals(listOf(line(2), line(3)), cache.newest(2))
// More than there is is what there is, which is a short opening window and not a failure.
assertEquals(3, cache.newest(80).size)
}
@Test
fun a_gap_in_the_stream_closes_the_open_chunk_under_the_end_it_turned_out_to_have() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
// What a `reset` looks like from here: the next event is not the one after the last.
cache.append(line(90), 90)
cache.flush()
assertEquals(listOf("1-4.raw.jsonl", "90-open.raw.jsonl"), names())
// Nothing is served across the gap: the suffix is the newest chunk alone.
assertEquals(listOf(line(90)), cache.newest(80))
assertEquals(CachedTail(90, line(90)), cache.tail())
}
@Test
fun an_event_already_covered_is_not_written_again() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
cache.append(line(2), 2)
cache.flush()
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
}
@Test
fun an_adjacent_page_extends_the_suffix_and_a_gap_stops_it() {
val cache = session()
(100L..102L).forEach { cache.append(line(it), it) }
cache.flush()
// Adjacent: its end is the open chunk's first.
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
assertEquals(listOf(98L, 99L), seqs(cache.page(before = 100, limit = 2, rows = false)))
assertEquals(60L, seqs(cache.newest(80))?.first())
// Behind a gap: kept on disk, because paging usually closes the gap, but never served
// across it.
assertTrue(cache.storePage((1L..9L).map { line(it) }, 1, 10, rows = true))
assertNull(cache.page(before = 10, limit = 5, rows = false))
assertEquals(60L, seqs(cache.newest(200))?.first())
}
@Test
fun a_page_that_overlaps_what_is_here_is_not_stored() {
val cache = session()
cache.append(line(100), 100)
cache.flush()
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
assertFalse(cache.storePage((50L..79L).map { line(it) }, 50, 80, rows = true))
assertFalse(cache.storePage(emptyList(), 40, 60, rows = true))
assertEquals(listOf("100-open.raw.jsonl", "60-100.rows.jsonl"), names())
}
@Test
fun a_miss_is_null_and_never_an_empty_page() {
val cache = session()
(100L..102L).forEach { cache.append(line(it), it) }
cache.flush()
// At or below where the run starts, so what the reader is scrolling into is the server's.
// An empty list here would be read as the start of the conversation and would stop the
// transcript scrolling back at all.
assertNull(cache.page(before = 100, limit = 40, rows = true))
assertNull(cache.page(before = 40, limit = 40, rows = true))
assertNull(session("never-visited").page(before = 100, limit = 40, rows = true))
}
@Test
fun a_page_starts_from_anywhere_inside_the_run_not_only_at_a_boundary() {
val cache = session()
(1L..10L).forEach { cache.append(line(it), it) }
cache.flush()
// Where a warm open leaves the cursor: in the middle of the live run, because the screen
// drew the newest lines of it. A cache that could only answer at a chunk boundary would
// send this to the server -- and the page that came back would overlap the run and be
// thrown away, so the whole of the scroll back would be fetched again on every visit.
assertEquals(listOf(5L, 6L, 7L), seqs(cache.page(before = 8, limit = 3, rows = false)))
assertEquals((1L..7L).toList(), seqs(cache.page(before = 8, limit = 99, rows = false)))
}
@Test
fun a_page_counted_in_rows_folds_each_delta_run_into_one_and_cuts_only_between_rows() {
val cache = session()
// Two replies of three deltas each, split by a tool call: the same fixture as the
// server's `coalescing_counts_rows_and_joins_delta_runs`.
val lines =
listOf(delta(1), delta(2), delta(3), line(4), delta(5), delta(6), delta(7), line(8))
write("1-9.raw.jsonl", lines)
cache.append(line(9), 9)
cache.flush()
// Three rows: the tool call at 8, the run 5..7, and the tool call at 4. The cut lands
// between rows, so the older run is not started.
assertEquals(
listOf(4L, 5L, 6L, 7L, 8L),
seqs(cache.page(before = 9, limit = 3, rows = true)),
)
// One row is one whole run, however many deltas it is made of.
assertEquals(listOf(8L), seqs(cache.page(before = 9, limit = 1, rows = true)))
// A page of lines counts lines, which is what the anchor restore asks for.
assertEquals(listOf(7L, 8L), seqs(cache.page(before = 9, limit = 2, rows = false)))
}
@Test
fun a_row_page_crosses_a_chunk_boundary_and_stops_short_at_the_oldest_chunk() {
val cache = session()
write("5-9.raw.jsonl", listOf(delta(5), delta(6), line(7), delta(8)))
cache.append(delta(9), 9)
cache.append(line(10), 10)
cache.flush()
// A run straddling the boundary is one row, as it will be once folded.
assertEquals(listOf(8L, 9L, 10L), seqs(cache.page(before = 11, limit = 2, rows = true)))
// Asking for more rows than the suffix holds is a short page, not a failure and not a
// claim that the conversation starts here.
assertEquals((5L..10L).toList(), seqs(cache.page(before = 11, limit = 40, rows = true)))
}
@Test
fun the_floor_for_a_fetch_is_the_nearest_chunk_at_or_below_it() {
val cache = session()
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
write("10-40.rows.jsonl", (10L..39L).map { line(it) })
cache.append(line(90), 90)
cache.flush()
// The run behind the gap, which is what makes the fetched page adjacent to it: a page
// fetched before 90 with a floor of 39 stops at 40 and closes the gap exactly.
assertEquals(40L, cache.coveredUpTo(90))
assertEquals(40L, cache.coveredUpTo(41))
assertEquals(10L, cache.coveredUpTo(10))
// Nothing at or below the oldest chunk's start, so the page is bounded only by its limit.
assertNull(cache.coveredUpTo(9))
}
@Test
fun a_newest_chunk_that_is_not_raw_discards_the_session() {
val cache = session()
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
// Only reachable by dying between closing one live run and opening the next, and there is
// no cursor to be read off a coalesced line -- so the open is a cold one.
assertNull(cache.tail())
assertFalse(dirOf().exists())
}
@Test
fun a_half_written_last_line_is_dropped_and_the_file_repaired() {
val cache = session()
dirOf().mkdirs()
File(dirOf(), "1-open.raw.jsonl").writeText(line(1) + "\n" + line(2) + "\n" + """{"se""")
assertEquals(CachedTail(2, line(2)), cache.tail())
assertEquals(line(1) + "\n" + line(2) + "\n", File(dirOf(), "1-open.raw.jsonl").readText())
// And the run continues from where the good tail left off.
cache.append(line(3), 3)
cache.flush()
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
}
@Test
fun damage_anywhere_else_discards_the_session_when_a_read_reaches_it() {
val cache = session()
write("1-open.raw.jsonl", listOf(line(1), "not ours", line(3)))
// Not seen by the tail, which reads the newest line and stops -- reading a chunk from its
// end is exactly not reading the rest of it, and that is what keeps a warm open cheap on
// a conversation of tens of megabytes.
assertEquals(CachedTail(3, line(3)), cache.tail())
// Reached by a read that walks past it, and there is no honest way to say what a chunk
// covers with a line of it unreadable -- so what is served is nothing, and the session
// opens cold from here on.
assertEquals(emptyList(), cache.newest(80))
assertFalse(dirOf().exists())
assertTrue(said.any { it.contains("damaged") })
}
@Test
fun a_name_this_does_not_recognise_is_ignored() {
val cache = session()
write("notes.txt", listOf("hello"))
write("1-open.raw.jsonl", listOf(line(1)))
assertEquals(CachedTail(1, line(1)), cache.tail())
}
@Test
fun a_chunk_larger_than_one_read_block_is_walked_across_the_boundaries() {
val cache = session()
// Well past the 64 kB block the backwards reader takes at a time, so a page has to be
// stitched across several of them -- including a line that straddles a boundary, which
// is the case nothing else here would notice going wrong.
val padding = "x".repeat(300)
val lines = (1L..500L).map { """{"seq":$it,"ts":1.5,"type":"toolStart","id":"$padding"}""" }
write("1-open.raw.jsonl", lines)
assertEquals(500L, cache.tail()!!.seq)
assertEquals(lines.takeLast(80), cache.newest(80))
assertEquals(lines.subList(0, 400), cache.page(before = 401, limit = 999, rows = false))
// And a non-ASCII line, whose bytes a naive split could cut through a character.
val accented = """{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"""
cache.append(accented, 501)
cache.flush()
assertEquals(accented, cache.tail()!!.line)
}
@Test
fun eviction_takes_the_least_recently_touched_and_never_the_one_on_screen() {
val cache = cache()
listOf("old", "middle", "open").forEachIndexed { at, id ->
write("1-open.raw.jsonl", List(50) { line(it + 1L) }, id = id)
dirOf(id).setLastModified(1_000_000L + at * 1000L)
}
val each = dirOf("old").walkTopDown().filter { it.isFile }.sumOf { it.length() }
// Room for two of the three, so the oldest goes -- and the session being read never does,
// however long ago it was last touched.
cache.evictToBudget(keep = "open", budget = each * 2)
assertEquals(listOf("middle", "open"), File(temp, "v1/host_8443").list()!!.sorted())
cache.evictToBudget(keep = "open", budget = 0)
assertEquals(listOf("open"), File(temp, "v1/host_8443").list()!!.sorted())
}
@Test
fun retaining_deletes_exactly_the_sessions_the_server_no_longer_lists() {
val cache = cache()
listOf("a", "b", "c").forEach { write("1-open.raw.jsonl", listOf(line(1)), id = it) }
cache.retainOnly(setOf("a", "c"))
assertEquals(listOf("a", "c"), File(temp, "v1/host_8443").list()!!.sorted())
}
@Test
fun size_and_purge_are_the_two_halves_of_the_reload_button() {
val cache = session()
assertEquals(0L, cache.bytes())
(1L..5L).forEach { cache.append(line(it), it) }
cache.flush()
assertTrue(cache.bytes() > 0)
cache.purge()
assertEquals(0L, cache.bytes())
assertNull(cache.tail())
// And the session is usable again straight afterwards, which is what a reload does next.
cache.append(line(9), 9)
cache.flush()
assertEquals(listOf(9L), seqs(cache.newest(80)))
}
}
+57
View File
@@ -0,0 +1,57 @@
# Shared by transcript-bench.sh and stream-bench.sh: the parts of driving
# this app that both need and that neither should describe twice.
#
# Sourced, not run. It assumes android-env.sh has already been sourced, so
# `ui-trace` and `adb` are this checkout's.
#
# **Nothing here presses a coordinate.** Every control is found by the name
# it already carries for assistive technology, resolved from the screen at
# the moment of the gesture (`ui-trace record --do "tap 'Save'"`). A
# coordinate is a position measured once by hand, and anything that moves
# the control -- a button added to the row, a font size, another device --
# makes the tap land on whatever now sits there; the bench then reports a
# number that was never measured, which reads exactly like a result. That
# is not hypothetical: both scripts pressed the render-report button at
# `tap 723 205` until 2026-09-03, when it moved into the session settings
# dialog. The check that none has crept back:
#
# grep -n "tap [0-9]" app/*.sh
# The title of the session these benches open by default: whichever one the
# server lists first, which is the most recently active.
#
# Asked of the server rather than taken from the screen, because a row has
# no stable name of its own and "the first one" is a fact about the list
# rather than about a position on it. Passing -s overrides it, and that is
# the honest way to measure a particular session: the list is ordered by
# last activity, so "the first row" is a different session at different
# times of day.
first_session_title() {
./ui-sandbox.sh api /sessions |
python3 -c 'import json,sys; d=json.load(sys.stdin); print(d[0]["title"] if d else "")'
}
# Restarts the app and opens the session called $1.
open_session() {
adb shell am force-stop com.example.aiapp
adb shell am start -n com.example.aiapp/.MainActivity >/dev/null
sleep 5
ui-trace record -d 6000 --do "tap '$1'" --do 'wait 3000' \
-o /tmp/bench-open.txt >/dev/null
sleep 2
}
# Copies the app's render report to the clipboard and the log, through the
# session settings dialog it lives in.
#
# Pressed twice per run: once to empty the counters, and once at the end,
# so what comes back covers exactly the work between the two. Closing the
# dialog is part of it -- it covers the transcript, which is the thing the
# gestures in between are meant to reach.
copy_render_report() {
ui-trace record -d 7000 \
--do "tap 'Session settings'" --do 'wait 1500' \
--do "tap 'Copy'" --do 'wait 800' \
--do "tap 'Close'" --do 'wait 800' \
-o "${1:-/tmp/bench-report.txt}" >/dev/null
}
+5
View File
@@ -39,6 +39,11 @@ GLYPHS=(
U+F004D # md-arrow_left
U+F009A # md-bell
U+F04C5 # md-speedometer
U+F024B # md-folder -- dev-updater's too; a folder means the same in both
U+F0415 # md-plus -- likewise
U+F03EB # md-pencil
U+F0193 # md-content_save
U+F0224 # md-file_outline
U+F201 # fa-line_chart -- Font Awesome's, asked for by name
)
+5
View File
@@ -17,6 +17,11 @@ dependencyResolutionManagement {
include(":androidApp")
// E3 (RUST.md): the Kotlin/Java shell over android-shell's JNI bridge, a
// separate module from :androidApp so the ~13,000 lines of working Compose
// UI there are untouched. See shellApp/build.gradle.kts's module comment.
include(":shellApp")
// The app half of wg-app-link, resolved by path through the submodule so
// this checkout and the crate it consumes move together -- the same
// arrangement `server/` uses for the Rust half. See that repo's README.
+163
View File
@@ -0,0 +1,163 @@
plugins { alias(libs.plugins.androidApplication) }
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
// into Rust (`../../android-shell`). Deliberately its own module rather
// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines
// of working Compose UI this experiment does not touch, and the two can be
// installed side by side on the same development device (see
// `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme
// and Keystore alias are not the production app's). No Compose plugin, no
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain
// Java, and the CA constant below is generated as Java too.
//
// The CA this build pins is baked in the same way `androidApp`'s does --
// see that module's `build.gradle.kts` comment for the reasoning (the
// trust boundary follows the machine that builds, never a pasted copy).
// `PinnedCa.java`'s package must match `android-shell`'s
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
val pinnedCaPath: String =
System.getenv("AI_APP_CA")
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
"/ai-app/certs/ca.pem"
abstract class GeneratePinnedCa : DefaultTask() {
@get:Input abstract val caPath: Property<String>
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.NONE)
abstract val caCertificate: RegularFileProperty
@get:OutputDirectory abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val path = caPath.get()
val ca = File(path)
if (!ca.isFile) {
throw GradleException(
"No CA certificate at $path.\n" +
"Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it " +
"generates the CA this build pins.\n" +
"Set AI_APP_CA=/path/to/ca.pem to build against a different one."
)
}
val pem = ca.readText().trim()
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
throw GradleException("$path is not a PEM certificate.")
}
val dir = outputDir.get().dir("com/example/aiapp/shell").asFile
dir.mkdirs()
// Same reasoning as androidApp's generatePinnedCert: the text block
// must start immediately after the opening `"""`, or
// CertificateFactory stops recognising the "-----BEGIN" preamble.
File(dir, "PinnedCa.java")
.writeText(
"""
|// Generated from $path by the generatePinnedCa task. Do not edit.
|package com.example.aiapp.shell;
|
|public final class PinnedCa {
| private PinnedCa() {}
| public static final String PINNED_CA_PEM = ""${'"'}
|$pem""${'"'};
|}
|"""
.trimMargin()
)
}
}
val generatePinnedCa =
tasks.register<GeneratePinnedCa>("generatePinnedCa") {
val ca = file(pinnedCaPath)
caPath.set(pinnedCaPath)
if (ca.isFile) {
caCertificate.set(ca)
}
}
android {
namespace = "com.example.aiapp.shell"
compileSdk = 37
defaultConfig {
applicationId = "com.example.aiapp.shell"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
// Same reasoning and same key as androidApp's (see that module's comment): E5 (RUST.md)
// signs its own, Gradle-free build with this same keystore, and the two can only
// `adb install -r` over each other if they carry the same certificate.
val keystore = System.getenv("AI_APP_KEYSTORE")
signingConfigs {
if (keystore != null) {
create("release") {
storeFile = file(keystore)
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
keyAlias = "ai-app"
keyPassword = storePassword
}
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
if (keystore != null) signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
// E5 (RUST.md): the xtask dexes and packages this module's Java sources itself, but it does
// not resolve Maven dependencies -- reimplementing a dependency resolver was out of scope for a
// packaging step, so this one task is the single place Gradle still runs in that pipeline. It
// asks the dependency graph for the *post-transform* jars (AARs already unpacked to a classes
// jar, the same artifact type AGP's own dexing task consumes) rather than the raw configuration,
// which would hand back .aar files d8 cannot read directly.
val artifactType = Attribute.of("artifactType", String::class.java)
tasks.register("printRuntimeClasspathJars") {
description = "Writes the resolved release runtime classpath jars, one per line, for xtask."
val outputFile = layout.buildDirectory.file("xtask/runtime-classpath.txt")
outputs.file(outputFile)
val jars =
configurations
.getByName("releaseRuntimeClasspath")
.incoming
.artifactView { attributes.attribute(artifactType, "android-classes-jar") }
.files
// Captured as a plain FileCollection (not the ArtifactView itself, which the
// configuration cache cannot serialize) so this task is still cacheable.
inputs.files(jars)
doLast {
val file = outputFile.get().asFile
file.parentFile.mkdirs()
file.writeText(jars.joinToString("\n") { it.absolutePath })
}
}
androidComponents {
onVariants { variant ->
variant.sources.java?.addGeneratedSourceDirectory(generatePinnedCa, GeneratePinnedCa::outputDir)
}
}
dependencies {
// The Keystore-sealed enrollment (ServerStore/ServerSettings) --
// android-shell's settings.rs calls into this Kotlin class directly
// over JNI rather than re-sealing the token in Rust; see that file's
// module doc.
implementation(project(":link"))
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
// ServiceCompat -- android-shell's notify.rs calls these classes over
// JNI so the pre-26 fallback behaviour (no channels) lives once, in
// the library that already has it, rather than being re-derived as a
// set of Build.VERSION.SDK_INT branches in Rust.
implementation(libs.androidx.core.ktx)
}
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Mirrors androidApp's manifest (AGENTS.md: reuse it rather than
re-deriving it) for the permissions and declarations E3 actually
exercises. Not carried over: the QR scanner activity (this
experiment enrolls via the aiappshell://enroll deep link directly,
per AGENTS.md's ui-sandbox.sh banner) and the app icon warning
suppression below, for the same reason androidApp's is there. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application
android:label="AI Sessions (shell)"
android:allowBackup="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
tools:ignore="MissingApplicationIcon">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Enrollment: aiappshell://enroll?host=...&port=...&token=...,
per AGENTS.md's ui-sandbox.sh banner (fed to this app with
`adb shell am start -a android.intent.action.VIEW -d
'aiappshell://enroll?...'`, or -n'd at this component
directly if a second app also claims the aiapp scheme). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="aiappshell" android:host="enroll" />
</intent-filter>
<!-- The share sheet - see android-shell's share.rs. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<!-- specialUse, not dataSync, for the reason androidApp's manifest
gives: a connection that has to keep listening overnight
cannot accept dataSync's six-hour cap. -->
<service
android:name=".NotificationService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="E3 experiment: holds one connection to the sandbox server so a
session that needs an answer can be reported while the app is closed." />
</service>
</application>
</manifest>
Loaded 100 of 292 files, more files were not shown because too many files have changed in this diff. Show more